Skip to content

Data Retention Policy

Per-record-type retention based on Singapore regulatory requirements. Verify with legal before production launch — the values here are engineering's best-effort reading of the applicable acts.

Regulatory basis

ActScopeTypical retention
Employment Act (MOM) § 95Employment records2 years after employment ends
IRAS (Income Tax Act + CPF Act)Tax records, CPF filings5 years from the year of assessment
Personal Data Protection Act (PDPA)Any personal dataAs long as necessary + purpose limitation

The overlap is: payroll records include tax-relevant data, so IRAS's 5-year floor dominates the Employment Act's 2-year minimum.

Per-entity retention

EntityRetentionBasisPurge strategy
EmployeeShadowSnapshot5 years from cycle archiveIRAS (CPF YTD, wage data)Batch delete in retention-purge job
ExportRow5 years from cycle archiveIRAS (constitutes the payroll filing record)Batch delete with ExportBatch
ExportBatch5 years from cycle archiveIRASCascade delete rows + file
OutputRow5 years from cycle archiveIRAS (reconciliation evidence)Batch delete
OutputBatch5 years from cycle archiveIRASCascade
Submission + SubmissionItem5 years from cycle archiveIRAS (supporting payroll decisions)Cascade
File (fileKind=UPLOAD)5 years from cycle archiveIRAS (supporting documents)Delete DB row + S3 object
File (fileKind=GENERATED_EXPORT)5 years from cycle archiveIRASDelete DB row + S3 object
File (fileKind=IMPORTED_OUTPUT)5 years from cycle archiveIRASDelete DB row + S3 object
AuditEvent7 yearsBest practice + PDPA breach trailNever purged by retention job — manual archival to cold storage
StaffSession90 daysSecurity hygieneDaily purge of expired sessions
CycleRequest (magic-link)30 days after expiresAtSecurity hygieneDaily purge
DocumentClassification, DocumentExtraction, ExtractedFieldSame as parent FilePDPA (derived from personal data)Cascade with file delete
PostPayrollEvidence5 years from cycle archiveIRASDelete with cycle
ValidationRun, ValidationResult, WorkflowIssue5 years from cycle archiveIRAS supporting evidenceCascade
OutboxEvent (processed)30 days after processedAtOperational — not regulatedDaily purge
OutboxEvent (dead-lettered)Retained indefinitelyIncident investigationManual review + dismissal

What the retention-purge job does

apps/worker/src/handlers/retention-purge.ts:

  1. Finds cycles where overallStatus = 'ARCHIVED' AND closedAt < now - 5 years
  2. For each such cycle, emits a retention.purge_started audit event before deleting anything (so the audit trail captures the purge itself)
  3. Deletes in FK-safe order: export_rows → export_batches → output_rows → output_batches → validation_results → validation_runs → workflow_issues → submission_items → submissions → employee_shadow_snapshots → post_payroll_evidence → files → cycle_requests → payroll_cycles
  4. For each deleted file, enqueues a delete-s3-object job so the S3 bytes are removed
  5. Emits a retention.purge_completed audit event with the count of rows removed
  6. Never touches audit_events, staff_users, clients, client_contacts

Cadence

  • Scheduled monthly, 03:00 SGT on the 1st of the month
  • Runs in a dry-run mode (log only, no deletes) during the first month of any new deployment
  • Retention run completion is a monitored alert — missed runs page on-call

Safeguards

  • Client-level opt-out: a clients.retentionExempt flag (not yet implemented — TODO) lets compliance teams pin a client's data for legal hold
  • Legal hold override: manually setting payroll_cycles.retentionHoldUntil (TODO) blocks purge even if the age threshold is met
  • Dry-run mode: RETENTION_DRY_RUN=true logs what would be deleted without executing

What's NOT purged

  • clients and client_contacts — these are master data, retained indefinitely
  • audit_events — 7-year retention, archived manually to cold storage after that
  • staff_users — kept indefinitely for audit attribution (deactivated via isActive = false instead)
  • document_requirement_rules and client_auth_policies — configuration, retained indefinitely
  • export_template_versions — template history must survive for re-parse capability

Verification

After every retention run, an ops engineer checks:

sql
-- No ARCHIVED cycles older than 5 years should exist
SELECT COUNT(*) FROM payroll_cycles
WHERE overall_status = 'ARCHIVED' AND closed_at < NOW() - INTERVAL '5 years';

-- Audit events for the purge run
SELECT * FROM audit_events
WHERE event_type IN ('retention.purge_started', 'retention.purge_completed')
  AND occurred_at > NOW() - INTERVAL '1 day';

Open TODOs

  • [ ] Legal review of the Singapore retention values
  • [ ] Implement clients.retentionExempt flag
  • [ ] Implement payroll_cycles.retentionHoldUntil
  • [ ] Archive job that copies audit_events older than 7 years to cold storage before any deletion is considered
  • [ ] Build the retention-purge scheduled job (currently the handler exists but is not scheduled — see apps/worker/src/schedules/index.ts)

Internal use only — BreezyCorp