System Design
This page deepens the canonical architecture in architecture.md (ADR-001 to ADR-014). It covers the parts an engineer needs on day one: what runs where, how a request flows, how modules talk, and how the four core engines (policy, workflow, attendance day computation, payroll) work.
It does not repeat the Phase-5 material: the tenancy model comparison, the hosting options, billing and the scaling path. For those, read architecture.md and Deployment.
Related pages: Tech stack · Decision log · Data model overview · API overview · Events & webhooks.
1. Logical architecture (C4 container view)
The diagram is a C4 level-2 container view drawn as a Mermaid flowchart. Each box shows the container, its technology and its responsibility. One application cell is shown. Pooled and dedicated cells run identical containers (ADR-003).
1.1 Container responsibilities
| Container | Process | Owns | Must not |
|---|---|---|---|
| App API | PROCESS_ROLE=api | HTTP handling, validation, synchronous domain logic, writing to the outbox | Run anything longer than about 2 s. Long work is enqueued as a job. |
| Workers | PROCESS_ROLE=worker | BullMQ consumers, the outbox relay, the per-tenant scheduler, PDF/Excel generation, payroll calculation | Serve HTTP (apart from a health endpoint) |
| Ingest | PROCESS_ROLE=ingest | High-rate writes: device punches (R2 gateway) and GPS points (R3). Validates, buffers in Redis streams, then batch-inserts | Run domain workflows. It only records facts and emits events. |
| Control-plane API | separate image | Tenant registry, cell map, plans and entitlements, flags, billing, rollout orchestration, support-access grants | Read or write tenant business data |
| Web SPA / Platform console | static | UI only | Hold secrets. It never decides the tenant. |
All three app-plane roles run one image built from apps/api (ADR-001). A role only changes which NestJS modules bootstrap their HTTP controllers, queue consumers and schedulers.
2. Module boundaries and dependency graph
2.1 Module layout
Each module in apps/api/src/modules/<code>/ follows the same internal layout:
modules/lve/
├── lve.module.ts # NestJS module; exports only the public facade
├── public/ # the ONLY import surface for other modules
│ ├── lve.facade.ts # LeaveFacade: queries + commands other modules may call
│ ├── lve.events.ts # event type names + zod payload schemas (published)
│ └── index.ts
├── api/ # controllers, request/response DTOs (zod contracts)
├── application/ # use cases (one class per command), unit-of-work orchestration
├── domain/ # entities, value objects, policies; no Nest, no Prisma imports
├── infra/ # Prisma repositories, queue processors, adapters
└── __tests__/
The kernel (apps/api/src/kernel/) is not a business module. It provides tenant context, the unit of work, the outbox, audit, idempotency, files, the policy engine host, entitlements, the job framework and the event bus. Every module may depend on the kernel. Kernel tables use the sys_ prefix (see Data model overview).
2.2 Boundary rules
| # | Rule | Enforcement |
|---|---|---|
| B1 | A module owns its tables (<code>_*). Only its own infra/ layer reads or writes them. | ESLint boundaries plugin; a CI script that parses Prisma queries per module folder |
| B2 | Other modules import only from modules/<code>/public. | dependency-cruiser rule no-deep-import |
| B3 | Synchronous calls follow the DAG below. A call against the arrow direction is a CI failure. | dependency-cruiser rule generated from the DAG |
| B4 | Information that flows against the DAG uses domain events (outbox), never a direct call. | Code review + B3 |
| B5 | Cross-module foreign keys are allowed only towards upstream modules (for example att_day.employee_id → emp_employee). They protect integrity; they do not license joins in application code. | Migration lint |
| B6 | rpt is the only module allowed to read other modules' data in SQL, and only through versioned read views (rpt_v_*) that the owning module publishes. | DB grants on views; code review |
| B7 | domain/ code is framework-free (no NestJS, Prisma or I/O imports) so engines can be unit- and golden-tested. | ESLint no-restricted-imports |
| B8 | A module never reads tenant_id from a request body or DTO. It comes from TenantContext only. | A lint rule bans tenantId in DTO schemas |
| B9 | Ports for inverted dependencies (for example ApproverResolver in NTF) are defined by the consumer and implemented by the provider at bootstrap. | Code review |
2.3 Dependency graph (synchronous calls)
Arrows point from the caller to the callee. Dashed arrows are event subscriptions, which flow against the DAG.
Key decisions behind the shape:
- ATT owns the work calendar (shift, rotation, week-off plus ORG holidays). LVE needs it synchronously to count leave days and to apply sandwich rules, so
lve → attis a call. ATT learns about leave through events and keeps its own projection (att_leave_mark). The day computer therefore never calls LVE. - "Convert this absent day to leave" (regularization) is orchestrated by the client: the UI calls the LVE endpoint with
source=regularization. ATT does not call LVE. - Late-to-leave deductions (Spryple rules 22–24) are assessed by ATT and emitted as
att.late.threshold_breached. LVE posts theLATE_DEDUCTIONledger entry. - NTF has no dependency on EMP. It defines
ApproverResolverandRecipientResolverports; EMP registers adapters (reporting line, department head, location HR). This removes the EMP ↔ NTF cycle. - CLM does not depend on PAY. Approved claims flow to payroll as events and become
pay_inputrows. EXT does call PAY, because F&F is an off-cycle payroll run.
2.4 Extraction readiness
A module can be extracted into a service when the triggers in architecture.md fire (ingest, payroll compute). Because B1–B4 hold, extraction means replacing the in-process facade with an HTTP/queue client and moving its tables to a separate database. No other module changes.
3. Request lifecycle
Every tenant request follows the same pipeline. Middleware order is fixed in kernel/http/pipeline.ts.
| Step | Component | What happens | Failure |
|---|---|---|---|
| 1 | Edge (CDN/WAF, LB) | TLS, WAF rules, body-size limit (10 MB; uploads go straight to S3), request ID | 4xx at the edge |
| 2 | TenantResolverMiddleware | Host acme.spyrl.app or a verified custom domain → slug → tenantId, cellId, status, plan from Redis (TTL 60 s, invalidated by plt.tenant.* events), else the control plane (signed response). The public API takes the tenant from the token's tid claim; ingest takes it from the device serial. | 404 tenant-not-found, 421 wrong-cell, 403 tenant-suspended on writes |
| 3 | AuthGuard | Verify the access JWT (ES256, key ID per cell). Require tid = resolved tenant. Check the session ID against the Redis revocation set. For kiosk tokens, enforce the punch-only route allowlist. | 401 |
| 4 | MinAppVersionGuard | Mobile only: header Spyrl-App-Version below the tenant or platform minimum | 426 upgrade-required |
| 5 | EntitlementGuard | @RequiresEntitlement('ffm') checks the tenant's plan and add-ons (cached SDK) | 403 entitlement-missing |
| 6 | PermissionGuard + ScopeGuard | @Permission('lve.request.approve'). Loads the user's effective grants (cached by roles_hash). For an item route, it resolves the subject employee and checks that they are inside the granted scope (SELF, TEAM, DEPARTMENT, LOCATION, ORG) using emp_reporting_closure and the job assignment. For list routes, it injects a scope predicate into the repository query. | 403 permission-denied (audited) |
| 7 | ZodValidationPipe | Validates params, query and body against the shared contract in packages/contracts | 400 validation-failed with field errors |
| 8 | IdempotencyInterceptor | For POST and PATCH with Idempotency-Key: replay the stored response, or reserve the key | 409 idempotency-in-flight, 422 idempotency-key-reused |
| 9 | Application service | Runs the use case in a unit of work (below) | Domain errors → problem+json |
| 10 | Unit of work | BEGIN → set_config('app.tenant_id', tenantId, true) (the parameterised form of SET LOCAL) → reads and writes through Prisma → insert outbox rows → insert audit rows → COMMIT | Rollback; nothing is published |
| 11 | Response | ETag on GETs; traceparent echoed; OTel span closed with tenant.id and cell.id | — |
| 12 | Outbox relay (async) | Publishes committed events to subscribers and webhooks | Retries; see §4 |
3.1 The unit of work
// kernel/db/unit-of-work.ts
export class UnitOfWork {
constructor(private readonly prisma: PrismaClient, private readonly ctx: TenantContext) {}
async run<T>(fn: (tx: TenantTx) => Promise<T>, opts: { isolation?: 'ReadCommitted' | 'Serializable' } = {}): Promise<T> {
const tenantId = this.ctx.requireTenantId(); // throws if absent: fail closed
return this.prisma.$transaction(async (tx) => {
// SET LOCAL cannot take bind parameters; set_config(..., true) is the transaction-local equivalent.
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
await tx.$executeRaw`SELECT set_config('app.actor_id', ${this.ctx.actorId ?? ''}, true)`;
const outbox = new OutboxWriter(tx, tenantId);
const result = await fn({ tx, outbox, tenantId });
await outbox.flush(); // same transaction as the state change
return result;
}, { isolationLevel: opts.isolation ?? 'ReadCommitted', timeout: 10_000 });
}
}
Rules:
- Every database access from app code goes through
UnitOfWork.runor the read-onlyReadScope.run(sameset_config, read-only transaction). The bare Prisma client is not injectable outside the kernel. - Repositories still add
tenant_idto everywhereclause (defence in depth, and it helps the planner use the(tenant_id, …)indexes). - PgBouncer runs in transaction mode. Transaction-local settings are safe there; session-level
SETis banned by a lint rule. - Workers re-create the tenant context from the job payload (
tenantId), check it against the cached tenant registry (status must not bedeleted), and then use the same unit of work. - Cross-tenant maintenance jobs (usage snapshots, retention purges) run under a separate database role,
spyrl_maint(BYPASSRLS), in a separate process with its own allowlist of queries. See Data model overview.
4. Domain events and the transactional outbox
4.1 Event envelope
Events are named <module>.<entity>.<past-tense-verb> (for example att.day.computed, lve.request.approved, pay.run.locked). The envelope follows CloudEvents 1.0 attribute names.
{
"specversion": "1.0",
"id": "01933b6e-8f2a-7c41-9d0e-4b7a2f1c9e55",
"type": "lve.request.approved",
"source": "spyrl://cell/p1/lve",
"subject": "lve_request/01933b6a-1c00-7aa0-8a51-2f0b9c3d4e11",
"time": "2026-11-03T09:14:22.381Z",
"tenantid": "01928f00-0000-7000-8000-00000000a001",
"dataschema": "spyrl://schemas/lve.request.approved/1",
"actor": { "type": "user", "id": "01928f10-...", "onBehalf": false },
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"data": {
"requestId": "01933b6a-1c00-7aa0-8a51-2f0b9c3d4e11",
"employeeId": "01929a44-...",
"leaveTypeCode": "CL",
"fromDate": "2026-11-10",
"toDate": "2026-11-12",
"days": "3.0",
"aggregateVersion": 4
}
}
- Payloads carry IDs and the facts consumers need, never sensitive values (no salary amounts, Aadhaar, PAN or bank numbers). A consumer that needs more calls the owning module's facade.
- Payload schemas are zod schemas in
modules/<code>/public/<code>.events.ts. They are exported to JSON Schema for the events catalogue. A breaking change creates a newdataschemaversion; both versions are emitted during the deprecation window. aggregateVersionlets an order-sensitive consumer drop stale events.
4.2 Outbox flow
| Concern | Design |
|---|---|
| Atomicity | The event row commits with the state change, or neither does. |
| Wake-up latency | LISTEN/NOTIFY nudges the relay. A 1 s poll is the fallback. Target: event to handler start in under 2 s p95. |
| Relay concurrency | One active relay per cell (a Redis lock with a 10 s lease) plus SKIP LOCKED, so a second relay is safe during failover. |
| Fan-out | Subscriptions are declared in code with an @OnEvent decorator that names the event type and a stable subscriber name (for example att.leave-mark). The relay creates one BullMQ job per subscriber. Webhook subscriptions are read from ntf_webhook_subscription. |
| Delivery semantics | At-least-once. Handlers are idempotent through sys_inbox_receipt (subscriber, event_id) written in the handler's own transaction. |
| Ordering | Not guaranteed across events. Handlers that care compare aggregateVersion or re-read current state ("event as a hint"). |
| Retention | Published rows are kept 7 days, then dropped by partition (weekly partitions). Unpublished rows older than 5 minutes raise an alert. |
| Redis loss | Redis is ephemeral (ADR-011). The relay republishes rows whose published_at is newer than the last checkpoint; the inbox dedupes them. |
| In-process synchronous hooks | None. A module that needs a synchronous reaction uses a facade call along the DAG. The one exception is the workflow decision callback (§7), which runs inside the deciding transaction. |
5. BullMQ job catalogue
5.1 Conventions
- Job names are commands:
<module>.<entity>.<imperative-verb>(for exampleatt.day.compute). Events are past tense; jobs are imperative. - Every job payload has
tenantId(except platform-wide jobs),requestedBy,traceparentand a schema version. Payloads hold IDs, not data. jobIdis the idempotency key. BullMQ ignores anaddwith ajobIdthat already exists, which gives free deduplication of bursts. Jobs that must re-run after completion use a key with a time bucket or an input hash.- Per-tenant fairness: each queue uses groups keyed by
tenantId, with a per-group concurrency limit and a global worker concurrency. This is the noisy-neighbour control in architecture.md. Decision pending: BullMQ Pro groups (licensed) versus an open-source fallback (a Redis semaphore pertenantIdand queue, acquired in the processor, with delayed re-queue when it is full). The catalogue below is the same either way. - Retry classes:
| Class | Attempts | Backoff | Use |
|---|---|---|---|
R-FAST | 5 | exponential, 2 s base, max 2 min | Cheap, idempotent internal work |
R-STD | 8 | exponential, 10 s base, max 30 min, ±20% jitter | Provider calls (email, SMS, push, maps) |
R-HEAVY | 3 | fixed 2 min | Payroll calculation, large exports, imports |
R-HOOK | 12 over about 23 h | immediate, then 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 3 h, 4 h, 4 h, 4 h, 4 h (±10% jitter) | Webhook delivery (events & webhooks) |
R-NONE | 1 | — | Jobs where a human must decide (for example statutory file generation after a validation failure) |
Validation and authorisation failures throw UnrecoverableError and skip retries. Exhausted jobs move to the dead-letter set (BullMQ failed set, kept 30 days), raise an alert tagged with the tenant, and are visible in the platform console with a "retry" action.
5.2 Queues
| Queue | Global concurrency (per worker) | Per-tenant group limit | Notes |
|---|---|---|---|
q.events | 32 | 8 | Outbox fan-out to subscribers |
q.att | 16 | 4 | Day computation, imports, device sync |
q.lve | 8 | 2 | Accruals, cycle close, expiry |
q.pay | 4 | 1 run-level, 8 line-level | Payroll calculation, payslips, files |
q.ntf | 32 | 10 | Notification sending (rate-limited per channel too) |
q.io | 4 | 1 | Imports, exports, report generation, tenant export |
q.ffm | 8 | 4 | GPS aggregation, distance, visit verification |
q.webhooks | 16 | 4 | Outbound webhooks; per-subscription circuit breaker |
q.sys | 2 | n/a | Maintenance, retention, scheduler tick |
5.3 Catalogue
| Job | Queue | Trigger | Idempotency key (jobId) | Tenant group | Retry | Notes |
|---|---|---|---|---|---|---|
sys.outbox.relay | q.sys | Repeatable every 1 s + NOTIFY | outbox-relay (singleton) | none | R-FAST | Leader-elected; see §4 |
sys.schedule.tick | q.sys | Repeatable every 60 s | tick:<yyyyMMddHHmm> | none | R-FAST | Finds tenants whose local time matches a schedule and enqueues their per-tenant jobs |
sys.file.scan | q.io | POST /sys/files/:id/complete | scan:<fileId> | tenantId | R-STD | ClamAV; marks sys_file_object.scan_status |
sys.retention.purge | q.sys | Daily 02:30 IST | purge:<date>:<dataClass> | none (spyrl_maint) | R-FAST | Applies the retention schedule in the data model overview |
sys.tenant.export | q.io | Control plane or org_owner request | export:<exportId> | tenantId | R-HEAVY | DPDP portability and offboarding; zip of CSV/JSON + documents |
sys.search.reindex | q.io | Migration or admin | reindex:<tenantId>:<entity> | tenantId | R-HEAVY | Rebuilds tsvector columns in batches |
ntf.webhook.deliver | q.webhooks | Outbox relay | <eventId>:<subscriptionId> | tenantId | R-HOOK | HMAC-signed POST (Spyrl-Signature); DLQ after the last attempt |
iam.invite.send | q.ntf | User invited or re-invited | invite:<inviteId>:<sendCount> | tenantId | R-STD | Email, SMS or WhatsApp |
iam.session.purge | q.sys | Daily | session-purge:<date> | none | R-FAST | Expired refresh-token families |
emp.import.validate | q.io | Upload of an import file | imp-validate:<batchId> | tenantId | R-NONE | Dry run; writes an error report |
emp.import.commit | q.io | User confirms the dry run | imp-commit:<batchId> | tenantId | R-HEAVY | Idempotent per row (row_hash) |
emp.probation.remind | q.ntf | Scheduler, daily 09:00 tenant time | prob:<tenantId>:<date> | tenantId | R-STD | Confirmation due list |
att.day.compute | q.att | att.punch.recorded (debounced 120 s), request/leave/shift/holiday changes | day:<employeeId>:<date>:<inputsHash> | tenantId | R-FAST | §8. Skips when the stored input_hash already matches |
att.range.recompute | q.att | Policy version published, holiday or rotation change, late device data | range:<scopeHash>:<from>:<to>:<trigger> | tenantId | R-STD | Fans out to att.day.compute in chunks of 500 employee-days |
att.day.close | q.att | Scheduler: shift end + grace for each location | close:<locationId>:<date> | tenantId | R-FAST | Applies the default punch-out (Spryple rule 35) and marks missed punches |
att.period.evaluate | q.att | Scheduler at period end; payroll prepare | period-eval:<periodId>:<version> | tenantId | R-STD | Lates → att.late.threshold_breached; extra days; week-off penalties |
att.import.commit | q.io | Attendance Excel/CSV import confirmed | att-imp:<batchId> | tenantId | R-HEAVY | Writes punches with source=import |
att.device.sync | q.att | Ingest batch or sync agent | dev-sync:<deviceId>:<batchSeq> | tenantId | R-FAST | Maps device user IDs to employees and records punches |
att.face.verify | q.att | Punch with a selfie when the policy needs a server-side match | face:<punchId> | tenantId | R-STD | Provider call; updates att_punch.verification |
lve.accrual.run | q.lve | Scheduler on the credit date (monthly, quarterly, half-yearly, yearly) | accrual:<leaveTypeId>:<periodKey> | tenantId | R-STD | Posts credit ledger entries; pro-rata for joiners |
lve.cycle.close | q.lve | Scheduler at leave-cycle end | cycle-close:<cycleId> | tenantId | R-HEAVY | Carry-forward cap, lapse, encashment candidates |
lve.compoff.expire | q.lve | Scheduler daily | compoff-exp:<tenantId>:<date> | tenantId | R-FAST | Posts expiry ledger entries |
pay.run.prepare | q.pay | POST /pay/runs/:id/prepare | prepare:<runId>:<attempt> | tenantId (limit 1) | R-HEAVY | Checks the attendance period lock, builds the employee set, pre-run validations |
pay.run.calculate | q.pay | POST /pay/runs/:id/calculate | calc:<runId>:<calcVersion> | tenantId (limit 1) | R-HEAVY | Fans out to pay.employee.calculate |
pay.employee.calculate | q.pay | Child of pay.run.calculate | emp:<runId>:<employeeId>:<calcVersion> | tenantId (limit 8) | R-FAST | Pure engine call (§9); writes pay_run_employee and its pay_run_line rows |
pay.payslip.render | q.pay | pay.run.locked | slip:<runEmployeeId>:<templateVersion> | tenantId | R-STD | PDF to S3 with Object Lock |
pay.payslip.deliver | q.ntf | pay.payslip.published | slip-mail:<payslipId> | tenantId | R-STD | Password-protected PDF email + push |
pay.file.generate | q.pay | User request after lock | file:<runId>:<fileType>:<version> | tenantId | R-NONE | ECR, ESIC, PT, LWF, bank NEFT, journal |
pay.advance.recover | q.pay | Part of calculation | n/a (inline) | — | — | Listed for completeness: recovery is computed inside the engine |
ntf.notification.send | q.ntf | Any event mapped to a template | ntf:<notificationId>:<channel> | tenantId | R-STD | Channel adapters; per-provider rate limits |
ntf.task.escalate | q.ntf | Delayed job at task SLA | esc:<taskId>:<level> | tenantId | R-FAST | Removed when the task is decided |
ntf.digest.send | q.ntf | Scheduler (daily approver digest) | digest:<userId>:<date> | tenantId | R-STD | Pending approvals summary |
rpt.export.generate | q.io | POST /rpt/exports | rpt:<exportId> | tenantId | R-HEAVY | Excel/PDF/CSV; scope-filtered at generation time |
rpt.schedule.dispatch | q.io | Scheduler | rpt-sched:<scheduleId>:<runDate> | tenantId | R-STD | Saved report → email |
clm.claim.check-duplicates | q.io | clm.claim.submitted | dup:<claimId> | tenantId | R-FAST | Bill hash matching |
ext.settlement.calculate | q.pay | POST /ext/separations/:id/settlement | fnf:<separationId>:<calcVersion> | tenantId | R-HEAVY | Uses the payroll engine in F&F mode |
ext.access.revoke | q.sys | Delayed job at LWD 23:59 local time | revoke:<employeeId>:<lwd> | tenantId | R-FAST | Disables the user and revokes sessions and devices |
eng.announcement.publish | q.ntf | Publish-from time reached | ann:<announcementId>:<publishVersion> | tenantId | R-STD | Audience expansion + notifications |
ffm.track.aggregate | q.ffm | Every 15 min per active field user; at punch-out | trk:<employeeId>:<date>:<bucket> | tenantId | R-FAST | Stops, stays, kms (snap-to-road) |
ffm.visit.verify | q.ffm | ffm.visit.checked_in | visit-verify:<visitId> | tenantId | R-FAST | Distance to the account location |
tsm.defaulters.notify | q.ntf | Scheduler weekly | ts-def:<tenantId>:<weekKey> | tenantId | R-STD | Missing timesheet nudges |
lms.certificate.render | q.io | lms.course.completed | cert:<enrollmentId> | tenantId | R-STD | PDF certificate |
plt.usage.snapshot | control plane | Daily 00:30 IST | usage:<cellId>:<date> | none | R-STD | Active employees, tracked users, messages, storage |
plt.tenant.provision | control plane | POST /tenants | prov:<tenantId> | none | R-STD (stepwise, resumable) | See API endpoints |
plt.cell.migrate | control plane | Rollout wave | migrate:<cellId>:<releaseSha> | none | R-NONE | Halts the wave on failure (ADR-010) |
6. Typed policy engine
Policies replace Spryple's numbered free-text rules ("Rule 1 … Rule 48") with typed, versioned, effective-dated documents (ADR-008). The engine lives in packages/policy and is shared by the API, workers and the web UI (which renders policy forms from the same schema).
6.1 Concepts
| Concept | Table | Meaning |
|---|---|---|
| Policy kind | code registry | A schema plus an evaluator, for example att.attendance, att.overtime, lve.leave_type, pay.payroll, iam.password, clm.expense, ffm.tracking, ext.notice. Each kind has a semver schema version. |
| Policy | <module>_policy (for example att_policy, lve_policy, pay_policy, clm_policy) | A named, tenant-owned instance of a kind ("Factory Shift Workers") |
| Policy version | <module>_policy_version | An immutable settings document with version_no, schema_version and valid_from/valid_to. Statuses: draft → published → superseded. Publishing validates against the schema. |
| Assignment | <module>_policy_assignment | Links a policy to an applicability rule (applicability JSONB) for a date range, with a priority |
Each module owns its three policy tables (boundary rule B1), but they all have the same shape, generated from one Prisma model template, so the kernel resolver works on any of them. The module's API exposes them as /api/v1/<module>/policies (see API endpoints).
6.2 Example policy document
An attendance policy version (kind att.attendance, schema 1.3.0). Durations are minutes; times are local to the employee's work location.
{
"kind": "att.attendance",
"schemaVersion": "1.3.0",
"name": "Factory Shift Workers",
"validFrom": "2026-12-01",
"document": {
"dayThresholds": { "fullDayMinutes": 480, "halfDayMinutes": 240 },
"grace": { "lateInMinutes": 10, "earlyOutMinutes": 10 },
"punchPairing": "FIRST_IN_LAST_OUT",
"duplicatePunchWindowSeconds": 60,
"missedPunch": { "defaultOutAfterShiftEndMinutes": 240, "markAs": "MISSED_PUNCH" },
"breaks": [
{ "code": "LUNCH", "durationMinutes": 30, "graceMinutes": 5, "deductFromWorked": true },
{ "code": "TEA", "durationMinutes": 10, "graceMinutes": 5, "deductFromWorked": false }
],
"lateDeduction": {
"enabled": true,
"freeLatesPerMonth": 3,
"deductEveryNLates": 3,
"deductDays": 0.5,
"leaveTypeOrder": ["CL", "EL", "LOP"]
},
"overtime": { "policyRef": "ot-factory", "minimumMinutes": 60, "requiresApproval": true },
"regularization": { "lookbackDays": 7, "allowOnWeekOff": false, "maxPerMonth": 4 },
"permission": { "maxPerDay": 1, "maxMinutesPerMonth": 240, "paid": true },
"capture": {
"allowedSources": ["BIOMETRIC", "MOBILE", "KIOSK"],
"selfieRequired": true,
"faceMatch": "FLAG_ON_MISMATCH",
"geofence": { "mode": "ENFORCE", "fallback": ["WIFI_BSSID", "QR"] }
},
"weekOffPenalty": { "enabled": true, "rule": "SANDWICHED_BY_ABSENCE", "designationIds": [] },
"extraDays": { "enabled": true, "slabRef": "extra-days-default" }
},
"applicability": {
"all": [
{ "field": "locationId", "op": "in", "values": ["01929b10-...-plant1", "01929b10-...-plant2"] },
{ "field": "employmentType", "op": "in", "values": ["PERMANENT", "CONTRACT"] }
],
"none": [ { "field": "designationId", "op": "in", "values": ["01929c22-...-security-supervisor"] } ]
},
"priority": 100
}
6.3 Schema and evaluator
// packages/policy/src/kinds/att.attendance/schema.ts
export const AttendancePolicyV1_3 = z.object({
dayThresholds: z.object({ fullDayMinutes: z.number().int().min(0).max(1440), halfDayMinutes: z.number().int().min(0) })
.refine((t) => t.halfDayMinutes <= t.fullDayMinutes, 'halfDayMinutes must be <= fullDayMinutes'),
grace: z.object({ lateInMinutes: z.number().int().min(0).max(240), earlyOutMinutes: z.number().int().min(0).max(240) }),
punchPairing: z.enum(['FIRST_IN_LAST_OUT', 'PAIRS']),
// ... every field carries .describe() help text; the UI form and the docs are generated from it
});
export const attendanceKind = definePolicyKind({
kind: 'att.attendance',
schemaVersion: '1.3.0',
schema: AttendancePolicyV1_3,
migrateFrom: { '1.2.0': (doc) => ({ ...doc, permission: { maxPerDay: 1, maxMinutesPerMonth: 240, paid: doc.paidPermission } }) },
evaluators: { classifyDay }, // pure functions, see §8
});
- The JSON Schema (draft 2020-12) is generated from zod at build time and published at
GET /api/v1/sys/policy-kinds/:kind/schemafor the UI form renderer and for documentation. - Evaluators are pure:
(policyDocument, facts) → decision. They never read the database or the clock. - Schema upgrades provide
migrateFromfunctions. Stored versions are migrated lazily on read and rewritten on next publish. A stored document is never silently mutated.
6.4 Resolution: which policy applies to employee E on date D
- Specificity ranks the most specific matched field: employee > designation > grade > department > location > employment type > legal entity > tenant default.
- Two time axes. A policy version changes the rules for everyone on that policy from a date ("from 1 Dec, grace becomes 10 minutes"). An assignment moves a population to another policy from a date ("Plant 2 moves to Factory Shift Workers from 1 Jan"). Both apply to existing employees, which fixes Spryple's "changes apply only to new employees" (SP-X-08).
- Conflict detection at publish time. Publishing a version or assignment runs a simulation over all active employees for the next 400 days. Two candidates with the same specificity and priority for the same employee and date block publication with a list of affected employees.
- Retroactive changes. A
valid_frominside a locked attendance or payroll period is rejected unless the user holds<module>.policy.backdate(for exampleatt.policy.backdate) and confirms. The change then triggersatt.range.recomputefor open periods and records "post-lock differences" that the next payroll run settles as arrears or recoveries. - Caching. Resolution results are cached per
(tenant, kind, employee, date)for 10 minutes and invalidated by<module>.policy.published,<module>.policy.assignedandemp.job.changed. - Simulation API.
POST /api/v1/sys/policies/:id/simulatereturns, for a date range, which employees change policy and a sample of recomputed attendance days, before anything is published.
7. Workflow and approval engine (NTF)
Approval chains are data, not code (ADR-008). Every request type uses the same engine and the same approval inbox: attendance requests, regularization, permissions, shift changes, overtime, leave and leave cancellation, comp-off, claims (verify → approve → pay), separation, advance salary, document approval, timesheets, travel, device approval, compensation changes and payroll run approval.
7.1 Definition model
{
"subjectType": "lve.request",
"name": "Leave - default",
"version": 3,
"validFrom": "2026-11-01",
"applicability": { "all": [ { "field": "employmentType", "op": "in", "values": ["PERMANENT"] } ] },
"autoApprove": [ { "when": { "actorIsApproverOf": "requester" }, "reason": "ON_BEHALF" } ],
"steps": [
{
"key": "manager",
"approvers": [ { "resolver": "reportsTo", "level": 1 } ],
"mode": "ANY",
"slaHours": 48,
"onSla": { "action": "ESCALATE", "to": { "resolver": "reportsTo", "level": 2 } },
"skipIf": { "approverIsRequester": true }
},
{
"key": "hr",
"when": { ">": [ { "var": "facts.days" }, 3 ] },
"approvers": [ { "resolver": "role", "role": "hr_manager", "scope": "LOCATION" } ],
"mode": "ANY",
"slaHours": 72,
"onSla": { "action": "REMIND", "everyHours": 24 }
}
],
"onReject": "END",
"allowWithdraw": "UNTIL_FIRST_DECISION"
}
| Element | Options |
|---|---|
| Approver resolvers | reportsTo (level N), departmentHead, role + scope (resolved against the requester's location or department), user, employeeField (for example the project manager), expression |
| Step mode | ANY (the first decision wins) or ALL (everyone must approve) |
| Conditions | JSONLogic-style expressions over facts that the subject supplies (days, amount, leave type, overlapping team absences). Decision pending: JSONLogic versus a CEL subset. The evaluator must be sandboxed and side-effect-free either way. |
| SLA | REMIND, ESCALATE (adds the target as an approver) or AUTO_APPROVE / AUTO_REJECT (only where the tenant enables it) |
| Delegation | ntf_delegation(from_user, to_user, subject types, from, to). Active delegations re-route new tasks and, optionally, open ones. |
| Auto-approval | On-behalf requests by an approver are auto-approved and labelled ON_BEHALF (matches Spryple SP-X-04, but audited). |
| Segregation of duties | A built-in guard: nobody approves their own request. For pay.run the editor of an included compensation change cannot approve the run (configurable). |
7.2 Runtime
Tasks (ntf_task) move through open → approved | rejected | delegated | escalated | skipped | expired.
Integration contract. The owning module implements WorkflowSubject and registers it with NTF:
export interface WorkflowSubject<F> {
subjectType: string; // 'lve.request'
facts(subjectId: string, tx: TenantTx): Promise<F>; // evaluated at start and at each step
onDecision(subjectId: string, outcome: 'APPROVED' | 'REJECTED' | 'WITHDRAWN', ctx: DecisionContext, tx: TenantTx): Promise<void>;
}
onDecision runs inside the same transaction as the task decision, so the leave request, the ledger debit and the task are always consistent. NTF also emits ntf.instance.completed. The definition version is pinned on the instance at start, so later edits to the definition don't change requests already in flight.
8. Attendance day-computation engine
The day computer turns raw facts into one att_day row per employee per work date. It is deterministic and idempotent: the same inputs always produce the same row. Module design detail is in Attendance.
8.1 Inputs and output
| Input | Source |
|---|---|
| Shift for the date | att_shift_assignment (direct or rotation step) → att_shift (start, end, crosses midnight, flexible hours) |
| Week-off | att_weekoff_assignment (pattern, for example "Sun + 2nd/4th Sat") |
| Holiday | ORG holiday calendar of the employee's location as of the date |
| Punches | att_punch in the window [shift start − 4 h, shift end + 8 h) (configurable), excluding rejected punches |
| Approved requests | att_request (WFH, WFO, on-duty, remote), att_regularization, att_permission, att_overtime |
| Leave | att_leave_mark projection (from LVE events), including half-day marks |
| Policy | The resolved att.attendance and att.overtime versions (§6) |
Output (att_day): status, first_half, second_half, first_in, last_out, worked_minutes, break_minutes, late_minutes, early_out_minutes, ot_minutes (eligible), ot_approved_minutes, permission_minutes, paid_fraction (0, 0.5 or 1), is_late, missed_punch, flags (GEOFENCE_EXCEPTION, FACE_MISMATCH, MULTI_SOURCE), policy_version_id, input_hash, engine_version, computed_at, is_locked.
A day has two halves, each with its own status code; the day status is derived from the halves. This is how half-day leave, half-day WFH and half-day absence combine without special cases (see Attendance).
8.2 Status codes
The status set covers Spryple's observed legend (§21.3 of the research) plus explicit codes for requests.
| Code | Meaning | Payable fraction (default) |
|---|---|---|
P | Present | 1 |
A | Absent | 0 (LOP if no leave is applied) |
HD | Half day (worked, below the full-day threshold) | 0.5 |
L | Leave (full day) | per leave type (paid 1, LOP 0) |
HL | Half-day leave + half-day worked | 1 if the leave is paid |
W | Week-off | 1 |
H | Holiday | 1 |
WP | Worked on a week-off | 1 + extra-day or comp-off eligibility |
HP | Worked on a holiday | 1 + extra-day or comp-off eligibility |
OD | On duty (approved) | 1 |
WFH | Work from home (approved) | 1 |
NJ | Not yet joined | 0 |
SEP | After separation (past the last working day) | 0 |
A missed punch is not a status: the day gets missed_punch = true, a provisional status per policy, and moves to the NeedsAction state (event att.day.needs_action) until it is regularized or the default out-time applies.
8.3 Algorithm
Rules that matter:
- Work date (
work_date) = the local date of the shift start in the work location's time zone. A night shift from 22:00 on 4 Nov to 06:00 on 5 Nov belongs to 4 Nov. Flexible shifts use the first punch, bounded by the policy window. - Late-arriving data (a biometric device that was offline for three days) simply triggers recompute of the affected dates. Because
jobIdincludes the input hash, bursts of punches collapse into one computation. - Concurrency: the job takes
pg_advisory_xact_lock(hash(tenant_id, employee_id, date))and upsertsatt_dayon(tenant_id, employee_id, work_date). - Period evaluation (
att.period.evaluate) runs the month-level rules after daily rows exist: late counts →att.late.threshold_breached, extra-day slabs (SP-ATT-17), week-off penalties (SP-ATT-18), and OT totals. - Period lock: payroll
preparerequiresatt_period_lockfor the payroll window. After the lock, recompute writesatt_adjustmentrows instead of changingatt_day, and emitsatt.adjustment.created; the next run settles them. - Every computed day emits
att.day.computedwith the status and payable fraction, which payroll previews and LVE (comp-off eligibility forWP/HP) consume.
9. Payroll calculation engine
packages/payroll-engine is a pure, deterministic TypeScript library. It has no database access, no clock and no randomness; it knows nothing about NestJS. The pay module gathers inputs, calls the engine, and stores the snapshot. This is what makes runs reproducible and golden-testable (RSK-01 mitigation).
9.1 Interface
export interface PayInput {
engineVersion: string; // must match the library; checked
period: { start: string; end: string; payDate: string; daysInPeriod: number; basis: 'CALENDAR' | 'ORG_WORKING_DAYS' | 'FIXED_30' };
employee: { id: string; joinDate: string; exitDate?: string; stateCode: string; taxRegime: 'NEW' | 'OLD'; flags: Record<string, boolean> };
compensation: { structureVersionId: string; annualCtcPaise: bigint; components: ComponentDef[] };
attendance: { payableDays: string; lopDays: string; otMinutes: number; extraDays: string; weekOffPenaltyDays: string };
inputs: OneTimeInput[]; // arrears, variable pay, reimbursements, TDS override, other deductions
loans: LoanRecovery[];
statutory: StatutoryPackSnapshot; // EPF/ESI/PT/LWF/TDS tables effective for the period
ytd: YtdTotals; // for TDS projection
declarations?: TaxDeclarationSnapshot;
rounding: RoundingPolicy;
}
export function calculate(input: PayInput): PayResult; // throws EngineError with a code for invalid input
9.2 Calculation order
The order is fixed and versioned with the engine. Each step reads only outputs of earlier steps.
| Step | Calculation | Notes |
|---|---|---|
| 1 | Validate input; normalise to paise and decimal days | Rejects unknown component types |
| 2 | Proration factor = payable days ÷ basis days | Basis per pay schedule: calendar days, organisation working days or fixed 30 (SP-PAY-04) |
| 3 | Fixed earnings from the structure (flat, % of CTC, % of Basic, formula) × proration | Formula components use a sandboxed decimal expression language (decision pending: in-house grammar versus a vetted library) with no access outside the input |
| 4 | Variable earnings: OT (minutes × rate per policy), extra days, arrears, variable pay, approved reimbursements, bonus | From inputs and attendance |
| 5 | Gross earnings | Sum of steps 3–4 |
| 6 | Statutory wage bases: EPF wages, ESI wages, PT gross, LWF base, and the Labour Codes wage definition check (excluded allowances above 50% added back) | Per component flags epfEligible, esiEligible, ptEligible. The rates and ceilings come from the statutory pack. Examples to verify with counsel. |
| 7 | Employee statutory deductions: EPF (employee share, on capped or actual wages per election), ESI (if wages ≤ the threshold at contribution-period start), PT (state slab, month-specific rules), LWF (state, frequency) | ESI eligibility locks for the contribution period |
| 8 | TDS: projected annual taxable income (YTD + current + projected remaining months − exemptions per regime and declarations) → annual tax with rebate, surcharge and cess → remaining tax ÷ remaining months | A manual override in inputs is supported and flagged |
| 9 | Other deductions: advance and loan instalments, other deductions master, notice recovery (F&F mode) | Capped so that net pay ≥ the policy minimum; any shortfall carries forward as a loan ledger entry |
| 10 | Net pay = gross − all employee deductions | Negative net is an error unless F&F mode allows recovery |
| 11 | Employer contributions: EPF split (EPS/EPF), EDLI, admin charges, ESI employer, LWF employer, gratuity provision | Not part of net; part of cost-to-company reports |
| 12 | Reconciliation: monthly CTC versus gross + employer contributions; variance reasons | Feeds the variance report |
9.3 Money and rounding
- All amounts are integer paise (
bigint). Intermediate values usedecimal.jswith 20 significant digits andROUND_HALF_UP. Floats are banned by lint in this package. - Rounding happens only at defined points, per component, using its
roundingsetting:NONE(to the paisa),NEAREST_RUPEE(default for earnings and deductions),UP_RUPEE,DOWN_RUPEE. Statutory contributions use the rule in the statutory pack (for example ESI rounded up to the next rupee). Example to verify. - Proration is applied to the unrounded monthly amount; the component is rounded once, after proration.
- Net pay is the sum of rounded components, so payslip lines always add up.
- Days are decimals with two places (
"14.50"), never minutes divided at the edge.
9.4 Run lifecycle and immutable snapshots
- On calculate, each
pay_run_employeerow storesinput_snapshot(the exactPayInput, JSONB),input_hash(SHA-256 of canonical JSON),engine_versionandoutput_snapshot, and the run gets onepay_run_lineper employee and component. - On lock, a trigger rejects any
UPDATEorDELETEon the run, itspay_run_employeerows and itspay_run_linerows. Payslip PDFs are written to S3 with Object Lock (compliance mode, 8 years by default). Corrections never edit a locked run; they create a reversal or off-cycle run that references it. - Reproducibility check:
POST /api/v1/pay/runs/:id/verifyre-runs the engine on every stored snapshot and compares output hashes. It runs automatically before lock and on a quarterly sample. - Statutory pack versions, policy versions and compensation versions are referenced by ID inside the snapshot, so a later change to reference data never alters a past run.
9.5 Golden tests
| Item | Rule |
|---|---|
| Location | packages/payroll-engine/golden/<case-id>/input.json and expected.json |
| Coverage target | At least 150 cases before R1 GA: every component type, proration basis, EPF ceiling election, ESI threshold edges, PT for each supported state (including February rules), LWF frequencies, TDS old and new regimes, mid-month join and exit, LOP, arrears, loans, negative-net handling, F&F |
| Authoring | Cases are built from real (anonymised) pilot payslips and from statutory examples, then reviewed by a payroll specialist |
| CI | npm run test:golden -w packages/payroll-engine must pass on every PR. A diff in any expected output fails the build. |
| Changing an expected value | Only in a PR labelled payroll-golden-change, with a reviewer from the payroll owners group and a changelog entry. The engine version is bumped (minor for new behaviour, patch for fixes). |
| Property tests | fast-check: net = gross − deductions; line items add up; rounding never moves a component by more than ₹1; proration is monotonic in payable days |
| Performance | Benchmark: 1,000 employees in under 60 s of engine time on one worker (SLO: under 5 minutes end-to-end, see architecture.md) |
10. Cross-cutting concerns
10.1 File storage
| Aspect | Design |
|---|---|
| Key layout | t/<tenant_id>/<module>/<yyyy>/<mm>/<uuid> (ADR-007). The original file name is stored only in sys_file_object. |
| Upload | POST /api/v1/sys/files (purpose, content type, size) → presigned PUT (5 min TTL, exact content length and type) → client uploads → POST /sys/files/:id/complete → sys.file.scan → scan_status = clean → the file can be attached |
| Download | GET /sys/files/:id/download checks the permission of the owning entity, then redirects (302) to a presigned GET (5 min TTL, Content-Disposition: attachment) |
| Limits by purpose | Logo: png/jpg ≤ 1 MB. Selfie: jpeg ≤ 400 KB after client compression. Bill/receipt: jpg/png/pdf ≤ 10 MB. Employee document: pdf/jpg/png ≤ 10 MB. Import file: xlsx/csv ≤ 20 MB. |
| Immutability | Payslips, statutory files and audit exports go to a bucket prefix with Object Lock. Other objects are versioned. |
| Retention | Driven by data_class on the file record (see Data model overview) |
| Encryption | SSE-KMS. Face templates are not stored in S3; they are envelope-encrypted in the database or kept with the provider. |
10.2 Caching
| Cache | Key pattern | TTL | Invalidation |
|---|---|---|---|
| Tenant resolution | cp:tenant:host:<host> | 60 s | plt.tenant.* events |
| Entitlements and flags | cp:ent:<tenantId> | 60 s | plt.entitlements.changed, plt.flag.changed |
| Effective permissions | t:<tenantId>:perm:<userId>:<rolesHash> | 15 min | A role change updates roles_hash in the next token |
| Policy resolution | t:<tenantId>:pol:<kind>:<employeeId>:<date> | 10 min | <module>.policy.published, <module>.policy.assigned, emp.job.changed |
| Reporting closure | t:<tenantId>:team:<employeeId> | 10 min | emp.job.changed |
| Reference data (statutory packs) | ref:stat:<packVersion> | 24 h | Immutable by version |
Rules: no salary, bank or identity values in Redis; cache keys always include the tenant; a cache miss must be correct, so caching is never needed for correctness. The HTTP layer returns ETag and supports If-None-Match. The web app relies on TanStack Query's cache (stale time 30 s by default, 0 for approval inboxes).
10.3 Search
- PostgreSQL full-text search first. Searchable entities (employees, accounts, documents metadata, announcements, courses) have a generated
search_tsv tsvectorcolumn using thesimpleconfiguration plusunaccent(Indian names don't benefit from English stemming), with a GIN index. pg_trgmGIN indexes support typeahead and fuzzy matching on names, employee codes, phone numbers and emails.- RLS applies to search automatically, and scope predicates apply as for any list.
- Move to OpenSearch only if p95 search latency exceeds 300 ms at the largest tenant, or if cross-entity relevance ranking becomes a requirement. Decision pending until those triggers are seen.
10.4 Time zones and dates
| Kind of value | Storage | API format |
|---|---|---|
| An instant (punch, approval, login) | timestamptz (UTC) | ISO 8601 with offset: 2026-11-04T09:02:11+05:30 |
| A business date (attendance date, leave date, pay date) | date | 2026-11-04 |
| A time of day (shift start) | time (no zone) + the location's zone | 09:30 |
| A date range | date columns from_date/to_date or valid_from/valid_to, both inclusive; a null valid_to is open-ended | as above |
| Durations | integer minutes | integer |
- The tenant has a default time zone (
Asia/Kolkata); each work location can override it (for tenants with staff abroad). - "Today" is always computed for a specific location's zone, never the server's zone. Containers run in UTC.
- Mobile device time is not trusted. Each punch carries
device_time,server_received_atand, for offline punches,elapsed_since_sync_msfrom a monotonic clock. The server derivespunched_atfrom the last server sync plus the elapsed time, and flags skew above 5 minutes. - Libraries: decision pending between the Temporal API (via polyfill until Node ships it) and
date-fns+date-fns-tz. PlainDatearithmetic is banned for business dates.
10.5 Money
- Store integer paise in
bigintcolumns named*_paise. API fields use thePaisesuffix as JSON integers (safe up to about ₹90 trillion). - Rates and percentages are
numeric(9,4)in the database and decimal strings in JSON ("12.0000"), parsed withdecimal.js. - Quantities of days are
numeric(6,2)and decimal strings in JSON ("1.50"). - Currency is INR only in R1–R3. A
currencycolumn exists on claim bills for foreign receipts, converted at submission with a stored rate. - Formatting (
₹1,23,450.00, Indian digit grouping) happens only in the UI and in templates.
10.6 Idempotency
| Layer | Mechanism |
|---|---|
| HTTP | Idempotency-Key header (required on punch, leave apply, claim submit, payroll actions, provisioning, all public API POSTs; optional elsewhere). Stored in sys_idempotency_key for 24 h with a request hash and the response. See API overview. |
| Natural keys | Unique constraints back up the header: att_punch (tenant_id, client_ref) and dedupe_key, att_day (tenant_id, employee_id, work_date), pay_run_employee (tenant_id, run_id, employee_id), lve_ledger_entry (tenant_id, source_type, source_id, entry_type) |
| Jobs | jobId per the catalogue (§5) |
| Events | sys_inbox_receipt (subscriber, event_id) |
| Webhooks | The event id is sent in the Spyrl-Event-Id header so receivers can dedupe |
| Offline mobile queue | The client generates a UUIDv7 per action. It is both the Idempotency-Key and the clientRef column. |
10.7 Audit
sys_audit_event is append-only (an INSERT-only grant for the app role, monthly partitions). It records actor, on-behalf actor, support-access grant ID, action (the permission key), entity type and ID, a hash of the before and after state plus a redacted diff, IP, user agent, device ID and trace ID. Sensitive reads (salary, bank, identity documents) are audited as read actions. Tenants can export it; platform staff access is always visible to the tenant.
11. Frontend architecture
11.1 Web SPA (apps/web)
apps/web/src/
├── app/ # router, providers (QueryClient, auth, tenant branding, i18n)
├── modules/<code>/ # mirrors backend modules: routes, pages, components, hooks
│ └── api/ # typed hooks generated from packages/contracts (useLeaveRequests, useApproveLeave)
├── shared/
│ ├── ui/ # shadcn/ui components themed with Tailwind 4 tokens from packages/ui
│ ├── policy-forms/ # JSON-Schema-driven policy editor
│ ├── data-table/ # TanStack Table + server-side cursor pagination
│ └── auth/ # useCan('lve.request.approve'), <RequirePermission> route guards
└── main.tsx
| Concern | Choice |
|---|---|
| Routing | Role- and entitlement-aware route tree; lazy-loaded per module. Decision pending: React Router v7 (used in lookin-hr) versus TanStack Router. |
| Server state | TanStack Query. Query keys start with the tenant slug and module. Mutations invalidate by key prefix and use optimistic updates only for low-risk actions (mark notification read). |
| Forms | React Hook Form + zod resolvers from packages/contracts, so client and server validate with one schema |
| Permissions | The UI hides what you can't do, but the API is the authority. GET /api/v1/iam/me returns the permission set with scopes. |
| Branding | Tenant logo and colours load at boot from GET /api/v1/org/branding (public, cacheable) |
| i18n | i18next bundles; tenant label overrides are merged at boot |
| Errors | problem+json is mapped to toasts and field errors; Sentry with the tenant tag, PII scrubbed |
| Performance budget | Initial JS ≤ 250 KB gzip; route chunks ≤ 150 KB; LCP < 2.5 s on a mid-range Android phone over 4G |
11.2 Mobile app (apps/mobile, ADR-014)
One Expo Router app with navigation groups chosen at login from role and entitlements: (ess), (mss), (field), (claims) and (kiosk).
Offline queue. Punches, visit check-ins, activity notes and claim drafts are written first to a local SQLite outbox (expo-sqlite), then synced.
- Queue limits: 500 actions or 7 days, whichever comes first. Selfies older than the retention period are deleted locally after sync.
- Tokens live in
expo-secure-store. The refresh token is bound to the device ID registered iniam_device. - Location: foreground location for punches. Background tracking (FFM, R3) runs only between punch-in and punch-out, with an explicit consent screen and a persistent indicator (ADR-007).
OTA updates. EAS Update with channels production, staging and preview. runtimeVersion follows the native fingerprint policy, so a JS bundle is never delivered to an incompatible binary. Critical fixes ship as OTA; native changes ship through the stores. The API enforces a minimum app version (426 Upgrade Required with a store link). Rollouts go to 10% first, then 100%, gated on the Sentry crash-free rate (≥ 99.5%).
Kiosk mode.
| Aspect | Design |
|---|---|
| Enrolment | An HR admin creates a kiosk in mob_kiosk_device (location, allowed identification methods) and shows a one-time QR code. The tablet scans it and receives a kiosk token: location-scoped, punch-only, long-lived, revocable. |
| Lock-down | Android: screen pinning or lock-task mode (via an MDM when the tenant has one). iPad: Guided Access. The app hides all navigation except the kiosk flow; an admin PIN exits. |
| Identification | Face (with liveness, templates in mob_face_profile) or employee code + PIN. Face matching is consent-gated (ADR-007). Decision pending: on-device matching (templates cached on the kiosk, encrypted) versus server-side matching through the provider. Server-side is the default until the privacy review completes. |
| Offline | The kiosk caches the location's roster (names, codes, photo thumbnails, salted PIN hashes) for up to 24 h and queues punches as above. |
| Evidence | Each kiosk punch stores the capture photo (retention 180 days), the device ID, the liveness score and the match score. |
| Health | Heartbeat every 5 minutes; the admin console shows offline kiosks and queue depth. |