Skip to main content

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​

ContainerProcessOwnsMust not
App APIPROCESS_ROLE=apiHTTP handling, validation, synchronous domain logic, writing to the outboxRun anything longer than about 2 s. Long work is enqueued as a job.
WorkersPROCESS_ROLE=workerBullMQ consumers, the outbox relay, the per-tenant scheduler, PDF/Excel generation, payroll calculationServe HTTP (apart from a health endpoint)
IngestPROCESS_ROLE=ingestHigh-rate writes: device punches (R2 gateway) and GPS points (R3). Validates, buffers in Redis streams, then batch-insertsRun domain workflows. It only records facts and emits events.
Control-plane APIseparate imageTenant registry, cell map, plans and entitlements, flags, billing, rollout orchestration, support-access grantsRead or write tenant business data
Web SPA / Platform consolestaticUI onlyHold 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​

#RuleEnforcement
B1A 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
B2Other modules import only from modules/<code>/public.dependency-cruiser rule no-deep-import
B3Synchronous calls follow the DAG below. A call against the arrow direction is a CI failure.dependency-cruiser rule generated from the DAG
B4Information that flows against the DAG uses domain events (outbox), never a direct call.Code review + B3
B5Cross-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
B6rpt 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
B7domain/ code is framework-free (no NestJS, Prisma or I/O imports) so engines can be unit- and golden-tested.ESLint no-restricted-imports
B8A module never reads tenant_id from a request body or DTO. It comes from TenantContext only.A lint rule bans tenantId in DTO schemas
B9Ports 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 → att is 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 the LATE_DEDUCTION ledger entry.
  • NTF has no dependency on EMP. It defines ApproverResolver and RecipientResolver ports; 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_input rows. 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.

StepComponentWhat happensFailure
1Edge (CDN/WAF, LB)TLS, WAF rules, body-size limit (10 MB; uploads go straight to S3), request ID4xx at the edge
2TenantResolverMiddlewareHost 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
3AuthGuardVerify 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
4MinAppVersionGuardMobile only: header Spyrl-App-Version below the tenant or platform minimum426 upgrade-required
5EntitlementGuard@RequiresEntitlement('ffm') checks the tenant's plan and add-ons (cached SDK)403 entitlement-missing
6PermissionGuard + 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)
7ZodValidationPipeValidates params, query and body against the shared contract in packages/contracts400 validation-failed with field errors
8IdempotencyInterceptorFor POST and PATCH with Idempotency-Key: replay the stored response, or reserve the key409 idempotency-in-flight, 422 idempotency-key-reused
9Application serviceRuns the use case in a unit of work (below)Domain errors → problem+json
10Unit of workBEGIN → set_config('app.tenant_id', tenantId, true) (the parameterised form of SET LOCAL) → reads and writes through Prisma → insert outbox rows → insert audit rows → COMMITRollback; nothing is published
11ResponseETag on GETs; traceparent echoed; OTel span closed with tenant.id and cell.id—
12Outbox relay (async)Publishes committed events to subscribers and webhooksRetries; 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.run or the read-only ReadScope.run (same set_config, read-only transaction). The bare Prisma client is not injectable outside the kernel.
  • Repositories still add tenant_id to every where clause (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 SET is 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 be deleted), 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 new dataschema version; both versions are emitted during the deprecation window.
  • aggregateVersion lets an order-sensitive consumer drop stale events.

4.2 Outbox flow​

ConcernDesign
AtomicityThe event row commits with the state change, or neither does.
Wake-up latencyLISTEN/NOTIFY nudges the relay. A 1 s poll is the fallback. Target: event to handler start in under 2 s p95.
Relay concurrencyOne active relay per cell (a Redis lock with a 10 s lease) plus SKIP LOCKED, so a second relay is safe during failover.
Fan-outSubscriptions 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 semanticsAt-least-once. Handlers are idempotent through sys_inbox_receipt (subscriber, event_id) written in the handler's own transaction.
OrderingNot guaranteed across events. Handlers that care compare aggregateVersion or re-read current state ("event as a hint").
RetentionPublished rows are kept 7 days, then dropped by partition (weekly partitions). Unpublished rows older than 5 minutes raise an alert.
Redis lossRedis is ephemeral (ADR-011). The relay republishes rows whose published_at is newer than the last checkpoint; the inbox dedupes them.
In-process synchronous hooksNone. 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 example att.day.compute). Events are past tense; jobs are imperative.
  • Every job payload has tenantId (except platform-wide jobs), requestedBy, traceparent and a schema version. Payloads hold IDs, not data.
  • jobId is the idempotency key. BullMQ ignores an add with a jobId that 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 per tenantId and queue, acquired in the processor, with delayed re-queue when it is full). The catalogue below is the same either way.
  • Retry classes:
ClassAttemptsBackoffUse
R-FAST5exponential, 2 s base, max 2 minCheap, idempotent internal work
R-STD8exponential, 10 s base, max 30 min, ±20% jitterProvider calls (email, SMS, push, maps)
R-HEAVY3fixed 2 minPayroll calculation, large exports, imports
R-HOOK12 over about 23 himmediate, 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-NONE1—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​

QueueGlobal concurrency (per worker)Per-tenant group limitNotes
q.events328Outbox fan-out to subscribers
q.att164Day computation, imports, device sync
q.lve82Accruals, cycle close, expiry
q.pay41 run-level, 8 line-levelPayroll calculation, payslips, files
q.ntf3210Notification sending (rate-limited per channel too)
q.io41Imports, exports, report generation, tenant export
q.ffm84GPS aggregation, distance, visit verification
q.webhooks164Outbound webhooks; per-subscription circuit breaker
q.sys2n/aMaintenance, retention, scheduler tick

5.3 Catalogue​

JobQueueTriggerIdempotency key (jobId)Tenant groupRetryNotes
sys.outbox.relayq.sysRepeatable every 1 s + NOTIFYoutbox-relay (singleton)noneR-FASTLeader-elected; see §4
sys.schedule.tickq.sysRepeatable every 60 stick:<yyyyMMddHHmm>noneR-FASTFinds tenants whose local time matches a schedule and enqueues their per-tenant jobs
sys.file.scanq.ioPOST /sys/files/:id/completescan:<fileId>tenantIdR-STDClamAV; marks sys_file_object.scan_status
sys.retention.purgeq.sysDaily 02:30 ISTpurge:<date>:<dataClass>none (spyrl_maint)R-FASTApplies the retention schedule in the data model overview
sys.tenant.exportq.ioControl plane or org_owner requestexport:<exportId>tenantIdR-HEAVYDPDP portability and offboarding; zip of CSV/JSON + documents
sys.search.reindexq.ioMigration or adminreindex:<tenantId>:<entity>tenantIdR-HEAVYRebuilds tsvector columns in batches
ntf.webhook.deliverq.webhooksOutbox relay<eventId>:<subscriptionId>tenantIdR-HOOKHMAC-signed POST (Spyrl-Signature); DLQ after the last attempt
iam.invite.sendq.ntfUser invited or re-invitedinvite:<inviteId>:<sendCount>tenantIdR-STDEmail, SMS or WhatsApp
iam.session.purgeq.sysDailysession-purge:<date>noneR-FASTExpired refresh-token families
emp.import.validateq.ioUpload of an import fileimp-validate:<batchId>tenantIdR-NONEDry run; writes an error report
emp.import.commitq.ioUser confirms the dry runimp-commit:<batchId>tenantIdR-HEAVYIdempotent per row (row_hash)
emp.probation.remindq.ntfScheduler, daily 09:00 tenant timeprob:<tenantId>:<date>tenantIdR-STDConfirmation due list
att.day.computeq.attatt.punch.recorded (debounced 120 s), request/leave/shift/holiday changesday:<employeeId>:<date>:<inputsHash>tenantIdR-FAST§8. Skips when the stored input_hash already matches
att.range.recomputeq.attPolicy version published, holiday or rotation change, late device datarange:<scopeHash>:<from>:<to>:<trigger>tenantIdR-STDFans out to att.day.compute in chunks of 500 employee-days
att.day.closeq.attScheduler: shift end + grace for each locationclose:<locationId>:<date>tenantIdR-FASTApplies the default punch-out (Spryple rule 35) and marks missed punches
att.period.evaluateq.attScheduler at period end; payroll prepareperiod-eval:<periodId>:<version>tenantIdR-STDLates → att.late.threshold_breached; extra days; week-off penalties
att.import.commitq.ioAttendance Excel/CSV import confirmedatt-imp:<batchId>tenantIdR-HEAVYWrites punches with source=import
att.device.syncq.attIngest batch or sync agentdev-sync:<deviceId>:<batchSeq>tenantIdR-FASTMaps device user IDs to employees and records punches
att.face.verifyq.attPunch with a selfie when the policy needs a server-side matchface:<punchId>tenantIdR-STDProvider call; updates att_punch.verification
lve.accrual.runq.lveScheduler on the credit date (monthly, quarterly, half-yearly, yearly)accrual:<leaveTypeId>:<periodKey>tenantIdR-STDPosts credit ledger entries; pro-rata for joiners
lve.cycle.closeq.lveScheduler at leave-cycle endcycle-close:<cycleId>tenantIdR-HEAVYCarry-forward cap, lapse, encashment candidates
lve.compoff.expireq.lveScheduler dailycompoff-exp:<tenantId>:<date>tenantIdR-FASTPosts expiry ledger entries
pay.run.prepareq.payPOST /pay/runs/:id/prepareprepare:<runId>:<attempt>tenantId (limit 1)R-HEAVYChecks the attendance period lock, builds the employee set, pre-run validations
pay.run.calculateq.payPOST /pay/runs/:id/calculatecalc:<runId>:<calcVersion>tenantId (limit 1)R-HEAVYFans out to pay.employee.calculate
pay.employee.calculateq.payChild of pay.run.calculateemp:<runId>:<employeeId>:<calcVersion>tenantId (limit 8)R-FASTPure engine call (§9); writes pay_run_employee and its pay_run_line rows
pay.payslip.renderq.paypay.run.lockedslip:<runEmployeeId>:<templateVersion>tenantIdR-STDPDF to S3 with Object Lock
pay.payslip.deliverq.ntfpay.payslip.publishedslip-mail:<payslipId>tenantIdR-STDPassword-protected PDF email + push
pay.file.generateq.payUser request after lockfile:<runId>:<fileType>:<version>tenantIdR-NONEECR, ESIC, PT, LWF, bank NEFT, journal
pay.advance.recoverq.payPart of calculationn/a (inline)——Listed for completeness: recovery is computed inside the engine
ntf.notification.sendq.ntfAny event mapped to a templatentf:<notificationId>:<channel>tenantIdR-STDChannel adapters; per-provider rate limits
ntf.task.escalateq.ntfDelayed job at task SLAesc:<taskId>:<level>tenantIdR-FASTRemoved when the task is decided
ntf.digest.sendq.ntfScheduler (daily approver digest)digest:<userId>:<date>tenantIdR-STDPending approvals summary
rpt.export.generateq.ioPOST /rpt/exportsrpt:<exportId>tenantIdR-HEAVYExcel/PDF/CSV; scope-filtered at generation time
rpt.schedule.dispatchq.ioSchedulerrpt-sched:<scheduleId>:<runDate>tenantIdR-STDSaved report → email
clm.claim.check-duplicatesq.ioclm.claim.submitteddup:<claimId>tenantIdR-FASTBill hash matching
ext.settlement.calculateq.payPOST /ext/separations/:id/settlementfnf:<separationId>:<calcVersion>tenantIdR-HEAVYUses the payroll engine in F&F mode
ext.access.revokeq.sysDelayed job at LWD 23:59 local timerevoke:<employeeId>:<lwd>tenantIdR-FASTDisables the user and revokes sessions and devices
eng.announcement.publishq.ntfPublish-from time reachedann:<announcementId>:<publishVersion>tenantIdR-STDAudience expansion + notifications
ffm.track.aggregateq.ffmEvery 15 min per active field user; at punch-outtrk:<employeeId>:<date>:<bucket>tenantIdR-FASTStops, stays, kms (snap-to-road)
ffm.visit.verifyq.ffmffm.visit.checked_invisit-verify:<visitId>tenantIdR-FASTDistance to the account location
tsm.defaulters.notifyq.ntfScheduler weeklyts-def:<tenantId>:<weekKey>tenantIdR-STDMissing timesheet nudges
lms.certificate.renderq.iolms.course.completedcert:<enrollmentId>tenantIdR-STDPDF certificate
plt.usage.snapshotcontrol planeDaily 00:30 ISTusage:<cellId>:<date>noneR-STDActive employees, tracked users, messages, storage
plt.tenant.provisioncontrol planePOST /tenantsprov:<tenantId>noneR-STD (stepwise, resumable)See API endpoints
plt.cell.migratecontrol planeRollout wavemigrate:<cellId>:<releaseSha>noneR-NONEHalts 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​

ConceptTableMeaning
Policy kindcode registryA 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_versionAn 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_assignmentLinks 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/schema for the UI form renderer and for documentation.
  • Evaluators are pure: (policyDocument, facts) → decision. They never read the database or the clock.
  • Schema upgrades provide migrateFrom functions. 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_from inside a locked attendance or payroll period is rejected unless the user holds <module>.policy.backdate (for example att.policy.backdate) and confirms. The change then triggers att.range.recompute for 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.assigned and emp.job.changed.
  • Simulation API. POST /api/v1/sys/policies/:id/simulate returns, 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"
}
ElementOptions
Approver resolversreportsTo (level N), departmentHead, role + scope (resolved against the requester's location or department), user, employeeField (for example the project manager), expression
Step modeANY (the first decision wins) or ALL (everyone must approve)
ConditionsJSONLogic-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.
SLAREMIND, ESCALATE (adds the target as an approver) or AUTO_APPROVE / AUTO_REJECT (only where the tenant enables it)
Delegationntf_delegation(from_user, to_user, subject types, from, to). Active delegations re-route new tasks and, optionally, open ones.
Auto-approvalOn-behalf requests by an approver are auto-approved and labelled ON_BEHALF (matches Spryple SP-X-04, but audited).
Segregation of dutiesA 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​

InputSource
Shift for the dateatt_shift_assignment (direct or rotation step) → att_shift (start, end, crosses midnight, flexible hours)
Week-offatt_weekoff_assignment (pattern, for example "Sun + 2nd/4th Sat")
HolidayORG holiday calendar of the employee's location as of the date
Punchesatt_punch in the window [shift start − 4 h, shift end + 8 h) (configurable), excluding rejected punches
Approved requestsatt_request (WFH, WFO, on-duty, remote), att_regularization, att_permission, att_overtime
Leaveatt_leave_mark projection (from LVE events), including half-day marks
PolicyThe 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.

CodeMeaningPayable fraction (default)
PPresent1
AAbsent0 (LOP if no leave is applied)
HDHalf day (worked, below the full-day threshold)0.5
LLeave (full day)per leave type (paid 1, LOP 0)
HLHalf-day leave + half-day worked1 if the leave is paid
WWeek-off1
HHoliday1
WPWorked on a week-off1 + extra-day or comp-off eligibility
HPWorked on a holiday1 + extra-day or comp-off eligibility
ODOn duty (approved)1
WFHWork from home (approved)1
NJNot yet joined0
SEPAfter 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 jobId includes 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 upserts att_day on (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 prepare requires att_period_lock for the payroll window. After the lock, recompute writes att_adjustment rows instead of changing att_day, and emits att.adjustment.created; the next run settles them.
  • Every computed day emits att.day.computed with the status and payable fraction, which payroll previews and LVE (comp-off eligibility for WP/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.

StepCalculationNotes
1Validate input; normalise to paise and decimal daysRejects unknown component types
2Proration factor = payable days ÷ basis daysBasis per pay schedule: calendar days, organisation working days or fixed 30 (SP-PAY-04)
3Fixed earnings from the structure (flat, % of CTC, % of Basic, formula) × prorationFormula components use a sandboxed decimal expression language (decision pending: in-house grammar versus a vetted library) with no access outside the input
4Variable earnings: OT (minutes × rate per policy), extra days, arrears, variable pay, approved reimbursements, bonusFrom inputs and attendance
5Gross earningsSum of steps 3–4
6Statutory 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.
7Employee 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
8TDS: projected annual taxable income (YTD + current + projected remaining months − exemptions per regime and declarations) → annual tax with rebate, surcharge and cess → remaining tax ÷ remaining monthsA manual override in inputs is supported and flagged
9Other 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
10Net pay = gross − all employee deductionsNegative net is an error unless F&F mode allows recovery
11Employer contributions: EPF split (EPS/EPF), EDLI, admin charges, ESI employer, LWF employer, gratuity provisionNot part of net; part of cost-to-company reports
12Reconciliation: monthly CTC versus gross + employer contributions; variance reasonsFeeds the variance report

9.3 Money and rounding​

  • All amounts are integer paise (bigint). Intermediate values use decimal.js with 20 significant digits and ROUND_HALF_UP. Floats are banned by lint in this package.
  • Rounding happens only at defined points, per component, using its rounding setting: 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_employee row stores input_snapshot (the exact PayInput, JSONB), input_hash (SHA-256 of canonical JSON), engine_version and output_snapshot, and the run gets one pay_run_line per employee and component.
  • On lock, a trigger rejects any UPDATE or DELETE on the run, its pay_run_employee rows and its pay_run_line rows. 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/verify re-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​

ItemRule
Locationpackages/payroll-engine/golden/<case-id>/input.json and expected.json
Coverage targetAt 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
AuthoringCases are built from real (anonymised) pilot payslips and from statutory examples, then reviewed by a payroll specialist
CInpm run test:golden -w packages/payroll-engine must pass on every PR. A diff in any expected output fails the build.
Changing an expected valueOnly 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 testsfast-check: net = gross − deductions; line items add up; rounding never moves a component by more than ₹1; proration is monotonic in payable days
PerformanceBenchmark: 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​

AspectDesign
Key layoutt/<tenant_id>/<module>/<yyyy>/<mm>/<uuid> (ADR-007). The original file name is stored only in sys_file_object.
UploadPOST /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
DownloadGET /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 purposeLogo: 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.
ImmutabilityPayslips, statutory files and audit exports go to a bucket prefix with Object Lock. Other objects are versioned.
RetentionDriven by data_class on the file record (see Data model overview)
EncryptionSSE-KMS. Face templates are not stored in S3; they are envelope-encrypted in the database or kept with the provider.

10.2 Caching​

CacheKey patternTTLInvalidation
Tenant resolutioncp:tenant:host:<host>60 splt.tenant.* events
Entitlements and flagscp:ent:<tenantId>60 splt.entitlements.changed, plt.flag.changed
Effective permissionst:<tenantId>:perm:<userId>:<rolesHash>15 minA role change updates roles_hash in the next token
Policy resolutiont:<tenantId>:pol:<kind>:<employeeId>:<date>10 min<module>.policy.published, <module>.policy.assigned, emp.job.changed
Reporting closuret:<tenantId>:team:<employeeId>10 minemp.job.changed
Reference data (statutory packs)ref:stat:<packVersion>24 hImmutable 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).

  • PostgreSQL full-text search first. Searchable entities (employees, accounts, documents metadata, announcements, courses) have a generated search_tsv tsvector column using the simple configuration plus unaccent (Indian names don't benefit from English stemming), with a GIN index.
  • pg_trgm GIN 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 valueStorageAPI 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)date2026-11-04
A time of day (shift start)time (no zone) + the location's zone09:30
A date rangedate columns from_date/to_date or valid_from/valid_to, both inclusive; a null valid_to is open-endedas above
Durationsinteger minutesinteger
  • 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_at and, for offline punches, elapsed_since_sync_ms from a monotonic clock. The server derives punched_at from 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. Plain Date arithmetic is banned for business dates.

10.5 Money​

  • Store integer paise in bigint columns named *_paise. API fields use the Paise suffix 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 with decimal.js.
  • Quantities of days are numeric(6,2) and decimal strings in JSON ("1.50").
  • Currency is INR only in R1–R3. A currency column 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​

LayerMechanism
HTTPIdempotency-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 keysUnique 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)
JobsjobId per the catalogue (§5)
Eventssys_inbox_receipt (subscriber, event_id)
WebhooksThe event id is sent in the Spyrl-Event-Id header so receivers can dedupe
Offline mobile queueThe 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
ConcernChoice
RoutingRole- and entitlement-aware route tree; lazy-loaded per module. Decision pending: React Router v7 (used in lookin-hr) versus TanStack Router.
Server stateTanStack 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).
FormsReact Hook Form + zod resolvers from packages/contracts, so client and server validate with one schema
PermissionsThe UI hides what you can't do, but the API is the authority. GET /api/v1/iam/me returns the permission set with scopes.
BrandingTenant logo and colours load at boot from GET /api/v1/org/branding (public, cacheable)
i18ni18next bundles; tenant label overrides are merged at boot
Errorsproblem+json is mapped to toasts and field errors; Sentry with the tenant tag, PII scrubbed
Performance budgetInitial 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 in iam_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.

AspectDesign
EnrolmentAn 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-downAndroid: 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.
IdentificationFace (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.
OfflineThe kiosk caches the location's roster (names, codes, photo thumbnails, salted PIN hashes) for up to 24 h and queues punches as above.
EvidenceEach kiosk punch stores the capture photo (retention 180 days), the device ID, the liveness score and the match score.
HealthHeartbeat every 5 minutes; the admin console shows offline kiosks and queue depth.