Skip to main content

Decision log

This page holds the full text of the architecture decisions summarised in §18 of architecture.md. The canonical summary there wins if the two ever disagree. Fix this page, or change the decision through a new ADR that supersedes the old one.

Format. Each ADR has a status, a date and five sections: Context, Decision, Consequences, Alternatives and Revisit when. ADR numbers are never reused. A superseded ADR stays here with status Superseded by ADR-NNN.

ADRTitleStatus
ADR-001Modular monolith with api / worker / ingest process rolesAccepted
ADR-002PostgreSQL + Prisma, shared schema with tenant_id and forced RLSAccepted
ADR-003Hybrid cells: pooled by default, dedicated on demand, one release trainAccepted
ADR-004Separate control planeAccepted
ADR-005Subdomain per tenant, company code for mobile, custom domains on EnterpriseAccepted
ADR-006Built-in auth, SSO and RBAC with data scopesAccepted
ADR-007India residency, envelope encryption, consent-gated biometrics and locationAccepted
ADR-008Configuration over customisationAccepted
ADR-009Staged hosting: Dokploy + RDS, then ECS Fargate + TerraformAccepted
ADR-010Trunk-based delivery, expand/contract migrations, wave rollouts, payroll freezeAccepted
ADR-011Backups and DR: RDS PITR, cross-region copies, drills, per-tenant exportAccepted
ADR-012OpenTelemetry + Grafana + Sentry with tenant-tagged telemetry and SLO gatesAccepted
ADR-013PEPM pricing, plan tiers, add-ons, Razorpay, control-plane meteringProposed
ADR-014Single Expo mobile app with role navigation and kiosk modeAccepted

ADR-001: Modular monolith with process roles​

Status: Accepted · Date: 2026-10-31

Context. One small team must deliver sixteen modules (see the module codes in CONVENTIONS) on one release train. The attend-to-pay chain (attendance → leave → payroll) needs consistent data across modules, and month-end creates spikes in calculation and notifications. Later, GPS ingest (R3) adds a write-heavy path with a very different load profile.

Decision. Build a NestJS 11 (TypeScript) modular monolith. Each module owns its tables and exposes a public facade and domain events; no module touches another module's tables. One image runs in three roles selected by PROCESS_ROLE=api|worker|ingest. Boundaries are enforced in CI (ESLint boundaries, dependency-cruiser); the rules B1–B9 and the dependency DAG are in System design.

Consequences.

  • Positive: one deployable artefact; ACID transactions across a use case; easy local development; fast delivery; refactoring across modules is a single PR.
  • Positive: the worker and ingest roles scale independently of the API, even though they share code.
  • Negative: everything scales together at the database tier, and a bad deploy affects all modules. This is mitigated by cells (ADR-003), canary waves (ADR-010) and feature flags.
  • Negative: boundaries depend on discipline. Without the CI checks this decays into a "big ball of mud".

Alternatives.

  • Microservices from day one: independent scaling and deploys, but distributed transactions across attend-to-pay, heavy operational overhead and slow delivery for a small team.
  • Serverless functions: pay-per-use, but cold starts, awkward long payroll jobs, hard local development and vendor lock-in.

Revisit when. GPS ingest sustains more than about 200 writes/s per cell (extract ingest); a payroll run takes more than 10 minutes for the largest tenant (extract payroll-compute); or the team grows past about four squads with conflicting release needs.


ADR-002: PostgreSQL + Prisma with a shared schema, tenant_id and forced RLS​

Status: Accepted · Date: 2026-10-31

Context. Most tenants are SMEs with 10–1,000 employees, so cost per tenant must be low. The data is highly sensitive (salary, Aadhaar, PAN, bank, biometric and location data), so a missing WHERE tenant_id = … must never leak data. The team already knows Prisma.

Decision. Use PostgreSQL 16+ (RDS) through Prisma. In pooled cells, use one shared schema with tenant_id on every tenant-owned table, and enable and force Row-Level Security with the policy tenant_id = current_setting('app.tenant_id')::uuid. The application connects as a role without BYPASSRLS; migrations run as a separate owner role. Every transaction sets app.tenant_id with set_config(..., true) (the transaction-local form of SET LOCAL) through a unit-of-work wrapper. Composite foreign keys include tenant_id. CI checks that every table with tenant_id has a policy, and runs isolation tests with two fixture tenants. The physical conventions are in Data model overview.

Consequences.

  • Positive: the lowest cost per tenant; onboarding in seconds; one migration run per cell; cross-tenant analytics for the platform is easy.
  • Positive: three layers of isolation (application filters, RLS, composite keys).
  • Negative: Prisma does not model RLS natively; we rely on a client extension and on interactive transactions. Connection pooling must use transaction mode.
  • Negative: a single-tenant restore needs a logical export/import tool (ADR-011).
  • Negative: a noisy tenant affects neighbours. Mitigated by per-tenant rate limits, job group quotas and cell moves.

Alternatives.

  • Schema per tenant: almost the same operational cost as a database per tenant (N migrations), with weaker isolation and catalogue bloat past about 1,000 schemas.
  • Database per tenant for everyone: strong isolation, but cost and migration overhead don't fit SME pricing. It is still available as the dedicated-cell option (ADR-003).
  • Drizzle or Kysely instead of Prisma: closer to SQL and RLS-friendly, but new to the team. Reconsider if the Prisma RLS spike (milestone A1) fails.

Revisit when. The A1 spike shows Prisma cannot reliably keep set_config and queries in one transaction under PgBouncer; RLS adds more than 15% p95 latency on hot paths; or a pooled cell's database exceeds 500 GB.


ADR-003: Hybrid cells, pooled by default and dedicated on demand​

Status: Accepted · Date: 2026-10-31

Context. Most customers want low prices. A few larger or regulated customers ask for physical isolation, their own maintenance windows or a contract that names their database. We cannot afford separate code branches or separate release trains.

Decision. Deploy the application plane as cells. A cell is a set of app containers + a PostgreSQL primary and replica + Redis + an S3 prefix. Lite, Standard and Professional tenants live in pooled cells. Enterprise tenants can have a dedicated cell: their own database on a shared app tier, or their own full stack (and optionally their own AWS account or VPC). All cells run the same images and migrations from one release train; dedicated cells are in a later rollout wave. Tenants move between cells with the per-tenant export/import tool and a routing switch in the control plane. New pooled cells are created at capacity thresholds, not per tenant.

Consequences.

  • Positive: a bounded blast radius (an outage, a bad migration or a noisy tenant affects one cell); enterprise isolation without a code fork; horizontal scale by adding cells.
  • Negative: we must build and test cell routing, per-cell migrations and tenant moves early.
  • Negative: more environments to monitor. Mitigated by tenant- and cell-tagged telemetry (ADR-012).

Alternatives.

  • One global pooled database: simplest, but an unbounded blast radius and no answer for enterprise isolation.
  • Single-tenant stacks for everyone: complete isolation, but N upgrades and a cost that SMEs won't pay.

Revisit when. More than 20 cells exist and routing or rollout becomes a bottleneck; or a regulation forces per-tenant physical isolation for all customers in a segment.


ADR-004: Separate control plane​

Status: Accepted · Date: 2026-10-31

Context. Something must know which tenants exist, where they run, which plan and features they have, how they are billed and which version their cell runs. Putting that inside each cell would duplicate it and make it impossible to route requests or roll out releases centrally.

Decision. Build a separate NestJS control-plane service with its own PostgreSQL database. It owns the tenant registry, cell registry, provisioning workflows, plans and entitlements, feature flags (OpenFeature-compatible), metering and billing, the rollout orchestrator, support-access grants and data-lifecycle actions (export, deletion, legal hold). Cells look up tenant → cell and entitlements through a signed, cached API (TTL 60 s, invalidated by events). The control plane never reads tenant business data. Its tables are in the control-plane section of the data dictionary.

Consequences.

  • Positive: one source of truth for tenancy and commercial state; safe central rollouts; a natural home for the platform console.
  • Negative: another service to run, and a dependency on the request path. Mitigated by caching, so cells keep serving known tenants for the cache TTL during a control-plane outage (and longer in degraded mode with the last known good entry).

Alternatives.

  • Tenant registry inside the app database: simpler at first, but it breaks with more than one cell and mixes platform and tenant data.
  • A third-party SaaS control plane or billing platform: faster to start, but entitlements and cell routing are core to us, and residency matters.

Revisit when. Never for the separation itself. Revisit the implementation if control-plane availability requirements exceed what one RDS instance and two containers can provide.


ADR-005: Tenant addressing and routing​

Status: Accepted · Date: 2026-10-31

Context. Spryple uses a company code on every login, which is clumsy on the web. Users need a memorable address, mobile users need a simple first-run step, and Enterprise clients often want their own domain.

Decision. The web app is at https://<slug>.spyrl.app (wildcard DNS and TLS; the slug is immutable, with alias redirects after a rename). The mobile app asks once for a company code, calls GET /public/tenants/resolve?code=…, and stores the API base URL and branding. Enterprise tenants can add a custom domain by CNAME to <slug>.cname.spyrl.app, verified by a TXT record, with automatic certificates. The public API is https://api.spyrl.app/v1, with the tenant taken from the OAuth token. Biometric devices post to https://ingest.spyrl.app/devices/:serial with a per-device secret. The tenant is never taken from the request body.

Consequences.

  • Positive: friendly URLs; per-tenant cookies and CSP; routing to cells by host.
  • Negative: wildcard certificates and on-demand TLS for custom domains need automation and abuse controls (domain verification before issuance).

Alternatives.

  • Path-based tenancy (spyrl.app/t/acme): shared cookies across tenants, weaker isolation in the browser, harder routing to cells.
  • Company code on the web too: familiar to Spryple users, but extra friction and no per-tenant origin.

Revisit when. We expand outside India and need regional hostnames, or a tenant needs several brands under one tenant.


ADR-006: Built-in authentication, SSO and RBAC with data scopes​

Status: Accepted · Date: 2026-10-31

Context. Spryple hands out the default password "Welcome1!" by plaintext email, and its permission model is undocumented. SMEs need simple logins (often mobile number + password for frontline staff); larger tenants need SSO. Every permission check in HR depends on whose data is being touched (self, team, location).

Decision.

  • Built-in authentication: argon2id password hashes with a breached-password check and a per-tenant password policy; activation only through a single-use invite link (72 h); a 15-minute ES256 access JWT (sub, tid, sid, roles_hash) plus a rotating refresh token with reuse detection; TOTP MFA (mandatory for admin and payroll roles); session listing and revocation; mobile device binding with optional manager approval; kiosk tokens that are location-scoped and punch-only.
  • SSO: OIDC with Google Workspace and Microsoft Entra ID (Standard+), SAML 2.0 on Enterprise. JIT linking only to existing employees by verified email.
  • Authorisation: roles are bundles of permissions named <module>.<resource>.<action>, granted with a data scope (SELF, TEAM, DEPARTMENT, LOCATION, ORG). Every check is the pair (permission, scope), for example lve.request.approve@TEAM. Sensitive fields need extra permissions (pay.compensation.read, emp.sensitive.read). Segregation of duties is configurable. See the RBAC permission matrix.

Consequences.

  • Positive: no per-MAU identity vendor cost; full control over company-code login, kiosk tokens and device binding; one guard pipeline for every route.
  • Negative: we own security-critical code. Mitigated by small, audited libraries, an external pentest before GA and OWASP ASVS L2 as the target.

Alternatives.

  • Auth0 / Cognito / Clerk: faster start, but cost at SME price points, residency questions and awkward kiosk and device-binding flows.
  • Keycloak: feature-rich, but heavy to operate for a small team.
  • Plain RBAC without scopes: simpler, but it can't express "approve leave for my team only" without one role per team.

Revisit when. Enterprise deals require identity features we can't build cheaply (SCIM provisioning, adaptive MFA), or a security review finds the in-house auth too risky.


Status: Accepted · Date: 2026-10-31

Context. Spyrl stores payroll, identity (Aadhaar, PAN), bank, health, biometric (face) and location data for Indian employees. The DPDP Act 2023 and its Rules, sector expectations and customer contracts require purpose limitation, consent for biometric and location data, retention limits and breach handling. Customers expect data to stay in India.

Decision.

  • All tenant data stays in AWS ap-south-1; encrypted backups are copied to ap-south-2.
  • TLS 1.2+ in transit; KMS encryption at rest (RDS, S3, EBS); application-level envelope encryption for Aadhaar, PAN, bank account numbers and face templates, with per-tenant data keys wrapped by KMS.
  • Data classification tags on fields and files drive masking, logging redaction, export rules and retention (see Data model overview).
  • Biometric and location processing only with recorded consent; templates rather than raw images where possible; location only between punch-in and punch-out with an on-screen indicator.
  • An append-only audit log, tenant-visible platform access, SAST/DAST in CI and an annual pentest.

Consequences.

  • Positive: defensible compliance posture; a sales advantage against tools that ship plaintext passwords.
  • Negative: encrypted fields can't be searched or indexed directly. We store a keyed hash (HMAC) for equality lookups, such as duplicate PAN detection, and show only the last four characters.
  • Negative: more complex key management and erasure workflows. Obligations to be verified with counsel.

Alternatives.

  • Encryption at rest only: simpler, but a database dump or an over-privileged query would expose identity numbers.
  • Multi-region hosting: not needed for India-only customers, and it complicates residency claims.

Revisit when. The DPDP Rules or sector guidance change; we sell outside India; or a customer requires customer-managed keys (BYOK), which would make per-tenant KMS keys the default for dedicated cells.


ADR-008: Configuration over customisation​

Status: Accepted · Date: 2026-10-31

Context. Spryple expresses its rules as about 48 numbered free-text questions, duplicates rules across screens, applies policy changes only to new employees, and fixes the list of leave types and pipeline stages. Each new client will ask for "just one change". Per-client code would destroy the single release train.

Decision. No per-client code branches. Client differences are expressed only through these layers: plan entitlements; feature flags; typed, versioned, effective-dated policies (JSON-Schema-validated, assigned by applicability rules and resolved per employee and date); masters and reference data; typed custom fields; sandboxed templates; workflow definitions as data; branding; localisation overrides; and per-tenant integration settings. The policy and workflow engines are specified in System design. A request that doesn't fit becomes a product feature behind a flag, available to every tenant on that plan.

Consequences.

  • Positive: policy changes apply to existing employees from a chosen date; one UI and one documentation source per rule (generated from the schema); predictable upgrades.
  • Negative: the engines are harder to build than hard-coded rules, and badly designed schemas become a long-term burden. Mitigated by schema versioning with migrations, simulation before publishing and conflict detection.

Alternatives.

  • Per-tenant code or scripts: maximum flexibility, but forks the product and makes testing impossible.
  • A generic rules engine (Drools-style): powerful but opaque to HR users and hard to validate.

Revisit when. More than 10% of sales opportunities are lost to customisation requests that the layers cannot express; then consider a sandboxed extension mechanism (for example tenant webhooks with synchronous validation).


ADR-009: Staged hosting​

Status: Accepted · Date: 2026-10-31

Context. Early on, cost and team familiarity matter most. Later, SLAs, dedicated cells and autoscaling matter more. Payroll data loss is existential at every stage. The hosting comparison is in architecture.md.

Decision. Stage A: Dokploy (existing team practice) on two EC2 hosts in different AZs for the app and workers, with managed RDS PostgreSQL Multi-AZ with PITR, ElastiCache, S3 and CloudFront. Dev, UAT and preview also run on Dokploy. Stage B: ECS Fargate per cell, defined by Terraform modules (a cell module containing services + RDS + Redis + S3 prefix + alarms), triggered by any of: more than 50 tenants or 10k employees, the first dedicated contract, a 99.9% SLA commitment, or more than about 4 h/week of host toil. Not chosen: EKS, on-premise, PaaS. Runbooks are in Deployment.

Consequences.

  • Positive: low early cost; skills the team already has; managed data tier from day one.
  • Negative: one planned migration from Stage A to Stage B. Mitigated by containers and Terraform modules from the start, so the move is a cutover, not a rewrite.

Alternatives. ECS from day one (more cost and setup before product-market fit); EKS (overhead); PaaS (residency and control); single-tenant VMs or on-premise (version sprawl).

Revisit when. The Stage B triggers fire, or Dokploy blocks a required capability (for example zero-downtime blue/green for the worker role).


ADR-010: Delivery, migrations and rollouts​

Status: Accepted · Date: 2026-10-31

Context. One release train must serve every cell, including dedicated cells with maintenance windows. Payroll runs on fixed dates, and a regression during the payroll window hurts every customer at once.

Decision.

  • Trunk-based development with short-lived branches; unfinished features stay behind flags.
  • Expand/contract migrations only: add (backward compatible) → deploy code that writes both and reads new → backfill in batched jobs → remove old structures in a later release. No destructive migration ships in the same release as the code that stops using the structure. The rules are in Data model overview.
  • Migrations run per cell, driven by the control plane's rollout orchestrator: staging → wave 0 (internal and canary tenants) → wave 1 (pooled cells, after 24 h of green SLOs) → wave 2 (dedicated cells, in their windows, after 48 h). A failure halts the wave.
  • Mobile: EAS Update for JS-only fixes; a minimum supported app version enforced by the API (426).
  • Public API /v1: additive changes only; deprecations announced 6 months ahead.
  • Payroll-window freeze from the 25th to the 5th: only reviewed patches.

Consequences.

  • Positive: rollback is redeploying the previous image, because the schema stays compatible; one bad release reaches only the canary wave.
  • Negative: two- or three-step migrations are slower to write. A release takes about 3–5 days to reach every cell.

Alternatives. Big-bang releases with downtime windows (unacceptable for punch capture); per-tenant versions (version sprawl).

Revisit when. Wave duration blocks urgent fixes regularly, or the cell count makes manual wave approval a bottleneck (automate the SLO gates fully).


ADR-011: Backups and disaster recovery​

Status: Accepted · Date: 2026-10-31

Context. Losing payroll or attendance data is existential. Pooled cells make single-tenant restore hard. Redis holds queues and caches.

Decision. RDS automated backups with PITR (35 days) and a Multi-AZ synchronous standby; daily snapshots copied to ap-south-2. S3 versioning, Object Lock (compliance mode for payroll artefacts) and cross-region replication. Redis is ephemeral: queues are rebuilt from the outbox and caches warm lazily. Nightly per-tenant logical export for Enterprise tenants, and on demand for anyone. Quarterly DR drills that measure actual RTO and RPO. Targets: RPO ≤ 5 min and RTO ≤ 1 h for an AZ or instance failure; RPO ≤ 24 h and RTO ≤ 8 h for a regional disaster.

Consequences.

  • Positive: tested recovery; single-tenant "undo" through restore-to-scratch and selective import.
  • Negative: the per-tenant export/import tool is essential and must be maintained with every schema change (it is covered by the migration checklist).

Alternatives. Logical dumps only (slow restore, larger RPO); a multi-region active-active database (cost and complexity not justified).

Revisit when. A customer contract requires RPO < 5 min for a regional disaster, or drills show RTO targets are missed.


ADR-012: Observability​

Status: Accepted · Date: 2026-10-31

Context. With many tenants per cell, "it is slow for us" must be answerable per tenant. Logs must not contain salary or identity data. Rollouts need objective gates.

Decision. OpenTelemetry for traces, metrics and logs, with tenant.id and cell.id attributes, exported to a Grafana stack (Tempo, Loki, Prometheus/Mimir); Sentry for errors on web, mobile and API; structured pino logs with PII redaction at the logger; high-cardinality per-tenant metrics only for the top-N tenants; business SLOs (payroll run success, payslip delivery, punch ingest lag, notification latency). Error budgets gate rollout waves (ADR-010).

Consequences.

  • Positive: per-tenant troubleshooting; vendor-neutral instrumentation; objective release gates.
  • Negative: telemetry cost grows with volume. Mitigated by sampling (tail-based for errors and slow traces) and retention tiers.

Alternatives. Datadog or New Relic (fast to adopt, expensive at scale); logs only (no tracing across api → queue → worker).

Revisit when. Telemetry cost exceeds about 8% of infrastructure cost, or Grafana Cloud residency options change.


ADR-013: Pricing, plans, metering and billing​

Status: Proposed (needs commercial validation) · Date: 2026-10-31

Context. SMEs compare price per employee. Some features have real variable costs (SMS and WhatsApp, GPS tracking, face verification, storage). Customers in India pay by UPI AutoPay, cards or e-mandate, and need GST invoices. The detailed billing design is in architecture.md and is not repeated here.

Decision. Per-active-employee-per-month (PEPM) pricing with plan tiers Lite, Standard, Professional and Enterprise; add-ons (Field Force per tracked user, Timesheets, LMS, face verification, WhatsApp); metered usage aggregated daily in the control plane; Razorpay Subscriptions; entitlements enforced by the cached SDK with soft limits before hard blocks; and never block payroll processing for billing reasons during the payroll window without senior approval.

Consequences.

  • Positive: a simple, comparable price; costs that scale with usage are passed through.
  • Negative: counting "active employees" must be exact and explainable (a daily snapshot, maximum in the month), and disputes need an audit trail.

Alternatives. Flat per-company tiers (unfair across sizes); per-module pricing (complex for SMEs); Stripe (weak India recurring support).

Revisit when. Pilot pricing interviews and the first 20 paying tenants show a different willingness to pay, or Razorpay limits block annual or enterprise invoicing.


ADR-014: One mobile app with role navigation and kiosk mode​

Status: Accepted · Date: 2026-10-31

Context. Spryple has four overlapping apps (HCM, Lite, CRM, Claims installed as "CRM"), with duplicated leave screens and inconsistent iOS support. Frontline workers often share devices or use a reception tablet.

Decision. A single Expo (Expo Router) app for Android and iOS. Navigation groups (ESS, MSS, Field, Claims) are chosen from role and entitlements at login. The app has an offline action queue for punches, visits and claim drafts, push notifications, and a kiosk mode that replaces Spryple's receptionist login: a location-scoped, punch-only kiosk token, face or PIN identification with liveness, and lock-down through screen pinning or Guided Access. "Lite" is a plan (an entitlement profile), not a separate app. The mobile architecture is in System design.

Consequences.

  • Positive: one codebase, one store listing, one login; features light up by entitlement.
  • Negative: a larger app bundle, and role-based navigation needs careful testing. Kiosk lock-down depends on OS features or an MDM.

Alternatives. Separate ESS, field and kiosk apps (simpler each, but repeats Spryple's fragmentation); a PWA only (weak background location, camera and kiosk control).

Revisit when. The bundle size or startup time hurts low-end Android devices (target: cold start under 3 s on a 3 GB RAM phone), or a large customer requires a separately branded kiosk app.


Pending decisions register​

These are open decisions raised while writing the architecture, data and API pages. Each becomes an ADR (ADR-015 onwards) once it is decided. Until then, the pages mark them decision pending.

TopicOptionsNeeded byWhere it matters
Per-tenant job fairnessBullMQ Pro groups (licensed) or an open-source Redis semaphoreA1 (R0)System design, job catalogue
Condition expression languageJSONLogic or a CEL subsetR1 workflow engineWorkflow conditions, policy applicability
Payroll formula languageIn-house decimal grammar or a vetted libraryR1 payrollFormula components
PDF renderingGotenberg (Chromium) or pdfmake / @react-pdf/rendererR1 payslipsPayslip throughput SLO
Date and time libraryTemporal polyfill or date-fns-tzA1 (R0)Attendance, leave, payroll dates
Web routerReact Router v7 or TanStack RouterA1 (R0)Web SPA
Connection poolerPgBouncer or RDS ProxyA3 (Stage A production)RLS with transaction-local settings
Face matching locationOn-device or server-side (provider)R2 kioskPrivacy review, offline kiosks
Multi-company tenantsSeveral legal entities under one tenant (separate EPF/ESI registrations) or one entity per tenantR1 data model freezeorg_legal_entity, statutory registrations
OpenAPI generationNative zod JSON Schema or zod-to-openapiR1API overview
Search engineStay on PostgreSQL FTS or add OpenSearchTrigger-basedDirectory and CRM search