Skip to content

PII Inventory ​

Every column in the BreezyCorp database that holds personally identifiable information, classified per the Singapore PDPA. This is the source of truth for:

  • Log redaction list (packages/observability/src/redact.ts, used by the API and the worker)
  • Sentry beforeSend scrub rules (scrubSentryEvent in packages/observability/src/sentry.ts)
  • Access audit queries during a tenant-leak investigation
  • DPIA / data subject access requests

Classification levels (increasingly sensitive):

  • PUBLIC — no access control needed
  • INTERNAL — BreezyCorp employees only
  • CONFIDENTIAL — client + BreezyCorp ops only; logged reads
  • RESTRICTED — encrypted at rest; logged reads + writes

Staff users ​

ColumnClassificationAt restRedacted in logsNotes
staff_users.emailCONFIDENTIALplainyes (regex in breadcrumbs)Used for login
staff_users.nameCONFIDENTIALplainnoDisplayed in audit trail
staff_users.password_hashRESTRICTEDbcrypt (cost 12)yes (*.password*)Not reversible
staff_users.mfa_secretRESTRICTEDAES-256-GCMyes (*.mfaSecret, *.mfa_secret)Encrypted with MFA_SECRET_KEY
staff_sessions.token_hashRESTRICTEDSHA-256yes (*.token*)Raw token never persisted
staff_sessions.csrf_tokenCONFIDENTIALplainyes (req.headers["x-csrf-token"])Double-submit pattern

Client contacts (portal users) ​

client_contacts and cycle_requests were the pre-Phase-0 tables; the greenfield schema keeps a company's contacts in payroll_contacts (all products) and every way to reach a person in contact_handles.

ColumnClassificationAt restRedacted in logsNotes
payroll_contacts.nameCONFIDENTIALplainnoDisplayed in audit
payroll_contacts.emailCONFIDENTIALplainyes (regex)Null for a contact reached only by WhatsApp or Telegram
payroll_contacts.pending_emailCONFIDENTIALplainyes (regex)An approver's new email waiting for a second console admin (24 Oct 2026)
payroll_contacts.pending_chat_handleCONFIDENTIALplainnoA phone, Telegram id or email waiting to be linked to an approver by a second admin (26 Oct 2026)
contact_handles.handle_valueCONFIDENTIALplainnoPhones (E.164), Telegram usernames and ids, emails. source = 'staff_linked' = linked from the Inbox
portal_invitations.token_hashRESTRICTEDSHA-256yesMagic-link token hashed; the raw token is never stored

Employee data (the high-sensitivity tier) ​

ColumnClassificationAt restRedacted in logsNotes
employee_shadow_snapshots.external_employee_idCONFIDENTIALplainnoClient-assigned ref; not globally unique
employee_shadow_snapshots.employee_nameCONFIDENTIALplainno
employee_shadow_snapshots.gross_payRESTRICTEDplain (DB-level encryption at rest via provider)yes (*.grossPay, *.gross_pay)Salary data
employee_shadow_snapshots.net_payRESTRICTEDplainyes (*.netPay, *.net_pay)
employee_shadow_snapshots.cpf_employeeRESTRICTEDplainyes (*.cpfEmployee, *.cpf_employee)CPF filing data
employee_shadow_snapshots.cpf_employerRESTRICTEDplainyes
employee_shadow_snapshots.ytd_ordinary_wageRESTRICTEDplainyes
employee_shadow_snapshots.ytd_additional_wageRESTRICTEDplainyesCPF AW ceiling calc
employee_shadow_snapshots.is_foreignCONFIDENTIALplainnoIR21 trigger
employee_shadow_snapshots.snapshot_jsonRESTRICTEDplain JSONByes (whole blob)Contains all of the above plus provider-specific fields

The payroll employee record (EMP-1, 26 Oct 2026) ​

One record per employee of a client company, kept across months, with every field Infotech needs. The fields are listed once in packages/contracts/src/payroll-employee-fields.ts; the ones marked sensitive there are the RESTRICTED values below. Reading one in full (POST …/payroll-employees/:id/reveal) writes a payroll.employee.value_revealed audit row naming who looked and which field, never the value. Payroll staff only (Payroll Executive, Payroll Lead, Admin, Platform Admin).

ColumnClassificationAt restRedacted in logsNotes
payroll_employees.full_nameCONFIDENTIALplainnoMirrors the current fullName value
payroll_employees.employee_codeCONFIDENTIALplainnoInfotech's code; links to employee_shadow_snapshots.external_employee_id
payroll_employee_values.value_encryptedRESTRICTEDAES-256-GCM, key PAYROLL_EMPLOYEE_ENCRYPTION_KEY (with rotation)n/a (ciphertext)NRIC/FIN, passport number, work pass number, bank account number, date of birth. Shown masked to the last four characters
payroll_employee_values.value_textCONFIDENTIALplainnoEvery other field: name, gender, marital status, emails, mobile, nationality, race, religion, salary, bank name and branch, allowances
payroll_employee_values.reasonCONFIDENTIALplainnoStaff's reason for changing a value on record; may describe the person

The values table is append-only: a change closes the old row (superseded_at) and writes a new one, so earlier values, sensitive ones included, stay in the database. Going live with real employee data waits for the PDPA owner's agreement to keep full NRIC/FIN, birth dates and bank numbers (payroll link work plan, blocker B14).

Column / storeClassificationAt restNotes
contact_link_codes.code_hashRESTRICTEDHMAC-SHA256 of the code and the row id, keyed by MAGIC_LINK_SECRETThe six-digit code itself is never stored. 10 minutes, single use
contact_link_codes.sent_toCONFIDENTIALplainThe contact's email when the code was sent, lower case
contact_link_sessions.token_hashRESTRICTEDSHA-256 of the cookie tokenThe token is only in the spade_link_session cookie (httpOnly)
contact_link_sessions.user_agent, device_idCONFIDENTIALplainBrowser description (300 characters at most) and a random browser marker (spade_link_device cookie, 400 days)
pg-boss job payroll.link-sign-in-codeRESTRICTED while liveplain JSON in pgboss.jobCarries the code and the address to the worker; kept as long as the job queue keeps finished jobs. A used or expired code is worthless (decision R32)

Audit rows for the link (payroll.link.*) name the contact by id and never hold the code or the email typed.

On the client's page (SIGN-3). A signed-in contact sees the company's employees with the same masking as staff: identity, passport, work pass and bank numbers to the last four characters, the date of birth not at all. Revealing one writes payroll.employee.value_revealed with actor_type = CLIENT, the contact and the session; at most 30 an hour per session. Staff names and their reasons are not shown to the client.

Uploads on the link (SIGN-4). contact_link_uploads records which contact uploaded which file (by id). The file is a files row with file_kind = PAYROLL_LINK_UPLOAD; its key is in the tenant's _inbox area and carries no name. files.scan_state / scan_detail hold the virus check; a failed file moves to <tenant>/_quarantine/ and the storage service will not read or link to it.

Request cards (CARD-1). request_cards holds what is asked of the client and their answer. A missing-detail answer goes onto the employee record (encrypted there when the field is sensitive) and is not copied onto the card (answer_note stays empty). answer_note holds the words of a typed account name or a "please confirm" answer; accepted_value holds a bank account name accepted on proof (the same name that is on the record in clear). An uploaded answer is a files row by id (answer_file_id), scanned as any link upload. Titles name the employee ("Ravi Kumar: NRIC / FIN number needed"), never a number. Reading cards (READ-1) add item_id, figure_key, figure_amount, source_file_id, value_id, answer_agrees and answer_amount: a month's money figure (a salary or claim amount, the same figure the month's card already holds) and the client's amount, in clear. On the record a reading card's title shows a hidden value masked ("…account number is ••••••7890"); the value itself stays only on the encrypted record, and a client's correction is written there, never on the card.

Every outgoing email passes through MaskingEmailSender (packages/notifications/src/adapters/masking.ts): an NRIC/FIN with a valid check letter in the subject or body is masked to its last four characters before sending, with a warning that gives only the count. Salaries and bank numbers are not caught by it; keeping them out of messages is the wording's job (MSG-1).

NRIC/FIN detection (26 Oct 2026). One check-letter test, isValidNricFin in @breezycorp/contracts (nric.ts), serves both the employee record's field rules and the document redaction that masks identity numbers before a model reads a document (packages/documents/src/redact/patterns.ts re-exports it). Before 26 Oct the redaction kept its own copy, which used the F/G check letters for the 2022 M-series FINs. M FINs whose check letter is J were therefore not recognised and reached the model unmasked, and M-shaped numbers ending in M were masked when they were not FINs. Documents read before the fix may have sent M-series FINs to the model in clear.

Routing excerpt (28 Oct 2026, stage review S2-08). Every inbound document's first 4,000 characters go to the classifier and, on an inbox with linked companies, to the company check, before anyone knows what the document is. Both now mask with the same pay profile the reading step uses for a payroll letter (redactText(..., { profile: 'pay' }), ROUTING_REDACTION_PROFILE in apps/worker/src/handlers/routing/classify-document.ts): salary amounts near a pay word and salary columns in a table are tokenised too, not only identity and account numbers. Before 28 Oct the routing step used the standard profile, so a salary written without S$/SGD reached the model. Names are still not masked anywhere (Open question 5, the PM's).

Client chat text (28 Oct 2026, stage review SX-40). Every model call that reads what a client typed masks it first with the same pay profile, through one helper, maskClientText in @breezycorp/documents: the message decision (body, thread, our questions), the older message classifier (the body and the last three messages), the routing classifier and the company check (the caption a document came with, and the client's answer to a clarifying question). Before 28 Oct the message classifier and the caption went to the model as typed, so an NRIC, an account number or "salary 4500" in a chat reached Gemini through OpenRouter.

Table columns (28 Oct 2026, stage review SX-42). In a tab-separated table (a workbook, or a digital PDF's text layer, which pdf-parse writes with tabs between cells) a header cell that names a salary, a bank account (Account, A/C, Bank Account No, IBAN) or a birth date (Date of Birth, DOB, Birth Date) marks its column, and every value beneath it is masked, pay profile only. A birth date in such a column loses only its day (⟦PII:DOB:1⟧/03/1985): the employee-list reader needs the birth month for the CPF age band. A scanned or photographed list comes from Vision as plain lines with no tabs, so this rule does not see it: there a bank number or birth date is masked only when its cue word is within 40 characters (the header row's words reach the first row only). Rebuilding a table from Vision's word positions is not built.

Offshore reader (28 Oct 2026, stage review SX-43). PAYROLL_TEXT_SOURCES and BOOKKEEPING_TEXT_SOURCES may not name openrouter at all: the worker refuses to start, naming the variable. Before, only openrouter ahead of vision was refused, so pdf-text-layer,vision,openrouter sent a scan Vision could not read offshore whole.

LangSmith traces (28 Oct 2026, stage review SX-41). With LANGSMITH_TRACING=true, the extract.document span records the reading's status, confidence, field count, field keys, text source and piiCounts, never a value; the extract.document.preview span records the preview's source, length, page count and whether it was cut, never its text. Neither span nor document.route carries the file's original name (it often names an employee); the file id is on document.route. traceRun takes an outputs filter for this; if the filter throws, the span records a stand-in, never the raw value. The prompts recorded by the model spans are the masked text the model was given.

Values read from a month's documents (28 Oct 2026, stage review SX-44). The month detail (GET /ops/payroll/cycles/:cycleId) returns an NRIC/FIN, bank account, tax reference or passport number read from a document as its last four characters, and a birth date hidden whole (masked: true), by field key with the lists in apps/api/src/lib/pii-mask.ts. POST /ops/payroll/cycles/:cycleId/fields/:fieldId/reveal (payroll staff) returns one in full with Cache-Control: no-store and writes the same audit row as a reveal on the employee record (payroll.employee.value_revealed, contains_pii), with target ExtractedField and the field, file and month, never the value.

Submission + payload data ​

ColumnClassificationAt restRedacted in logsNotes
submissions.attestation_textINTERNALplainnoFree-text client attestation
submissions.monthly_declarationCONFIDENTIALplain JSONB*.declarantName partialDeclarant name is PII
submission_items.employee_refCONFIDENTIALplainno—
submission_items.payload_jsonRESTRICTEDplain JSONByes (whole blob)Contains salary/NRIC/bank depending on change type

Payload JSON fields commonly contain:

  • previousSalary, newSalary — RESTRICTED
  • bankAccount, bankCode — RESTRICTED (redacted as *.bank_account, *.bankAccount)
  • nric, fin — RESTRICTED (redacted as *.nric, *.fin)
  • reason, position — CONFIDENTIAL

Files (uploaded documents) ​

ItemClassificationNotes
File bytes in S3RESTRICTEDContains offer letters, payslips, IR21 forms — highly sensitive. Encrypted at rest via S3 SSE-KMS.
files.original_nameCONFIDENTIALMay leak identity via filename
files.sha256INTERNALContent hash only
document_classifications.provider_payload_jsonRESTRICTEDOCR output may contain extracted PII
extracted_fields.raw_valueRESTRICTEDOCR extractions — redacted via *.rawValue

Audit events ​

ColumnClassificationAt restRedacted in logsNotes
audit_events.event_data_jsonVariesplainyes (selective — the outer blob is logged, sensitive fields inside are redacted via pino's deep-path config)Contains context for each audit event
audit_events.actor_idINTERNALplainnoStaff user id or 'portal'

Audit events are retained 7 years and never purged by the retention job (see docs/retention-policy.md).

Cross-reference: redaction configs ​

Log redaction (since SEC-5, 27 Oct 2026) ​

One list, LOG_REDACT_KEYS / LOG_REDACT_PATHS in packages/observability/src/redact.ts. The API's Fastify logger (apps/api/src/app.ts) and the worker's logger (createLogger) both use it, so a job log is redacted the same way as a request log. Before SEC-5 the worker's logger had no redaction at all, and the API's matched a key one level down only.

Each key is redacted at the top of a log line and one and two levels down (k, *.k, *.*.k; pino matches each depth separately). Deeper than two levels is still logged as it is (R112).

  • Headers: authorization, cookie, set-cookie (request and response), x-csrf-token, x-ocr-signature.
  • Secrets: password, passwordHash, oldPassword, newPassword, totpCode, mfaSecret, sessionToken, token, tokenHash, magicLinkUrl, csrfToken, secret, apiKey, codeHash (with their snake_case forms).
  • Identity and bank: nric, fin, nricOrFin, idNumber, passportNumber, workPassNumber, dateOfBirth, bankAccount, bankAccountNumber.
  • Pay: grossPay, netPay, cpfEmployee, cpfEmployer, ytdOrdinaryWage, ytdAdditionalWage, previousSalary, newSalary, newMonthlySalary, monthlySalary, salary, basicSalary.
  • Whole blobs: snapshotJson, payloadJson, rawValue.

The bare key code is not redacted (R113): every error carries a code (P2002, FST_ERR_…) and the log needs it. A one-time sign-in or approval code travels in a job payload, and job payloads are never logged whole (below).

A payroll link token (/portal/payroll/<token>, /portal/link/<token>) opens something on its own, so it is kept out of everything stored or logged:

  • Messages: recordMessage stores the body and the conversation preview with the link as …/portal/link/[link] (redactPortalLinks in @breezycorp/contracts). Staff resend a link from the company page; they never need the token (R111).
  • Send audits: the chat-reply and "payroll started" send audits store the redacted body.
  • Request logs: the API's request serializer logs /portal/link/[link] in place of the URL's token and everything after it.
  • Sentry: scrubSentryEvent applies the same rule to the event's request URL and query string, and scrubs headers, cookies, body, extras and contexts.

Dead jobs ​

A job that failed every retry logs, reports and audits its queue, its id, its payload's keys and its ids (tenantId, runId, cycleId, …) and the failure's message. Before SEC-5 it wrote the whole payload, which for a link job held the sign-in code, the address and the message text, to the log, Sentry and audit_events.

Staff edits of a month card ​

PATCH /ops/submission-items/:id audits submission_item.updated with each changed key from what to what (changes: [{ key, from, to }]), and marks the row contains_pii = true. An unchanged key is left out.

/portal/payroll/:token answers a 5xx with a plain sentence and the request id; the error's own text (a database error can quote a row) goes to the log only.

Data subject access / deletion requests (PDPA) ​

When a data subject (employee) requests access / deletion:

  1. Locate: query payroll_employees / payroll_employee_values (by name or employee code), employee_shadow_snapshots, submission_items, extracted_fields for the employee ref. Sensitive employee values need the encryption key to read; superseded versions are kept too
  2. Scope: verify the employee is associated with the requesting client (tenant scope)
  3. Decide: for deletion requests under PDPA, check whether the record is still within the 5-year tax retention window — tax records cannot be deleted on request, they must age out
  4. Act: via scripts/gdpr-export.ts / scripts/gdpr-delete.ts (TODO — build when first request lands)
  5. Log: every DSAR access creates an audit_events row with event_type = 'dsar.access' or dsar.delete

Review cadence ​

  • Quarterly: engineering reviews this doc against the schema for drift
  • Annually: legal + DPO review for regulatory change
  • On schema change: any PR adding a column to employee_shadow_snapshots, payroll_employees, payroll_employee_values, payroll_contacts, submission_items, or files, or a field to the employee field list, must update this inventory in the same commit

Client messages through Chatwoot (MSG-1, 27 Oct 2026). Every payroll message sent through Chatwoot passes guardClientMessage (packages/agent-runtime/src/modules/pay-message-guard.ts) in deliverToPayrollRecipient: a valid NRIC/FIN is masked on email and chat; on chat (Telegram, WhatsApp) amounts and runs of eight or more digits are masked as well. The log line records only how many values were masked. For a company with a live payroll link, chat messages carry counts and the link instead of employee names.

Who sees payroll (SEC-2, 27 Oct 2026). A payroll month's detail, its checks, its history and its staff actions, payroll files and the closing-question button answer payroll staff only: Payroll Executive, Payroll Lead, Admin, Platform Admin (operatorRolesForModule('PAY')); other staff get 403. The month list stays open. A file's download is decided by its module: bookkeeping kinds in payroll_files go to bookkeeping staff; a payroll-link upload or a document routed to a module goes to that module's staff; a file nothing ties to a module stays open to staff. The firm-wide audit trail (/ops/audit-events) and outbox list are for Admin and Platform Admin. The review inbox lists and opens only the tasks of modules the person may decide. Staff uploads name their month (cycleId on /ops/files/presign), so the file belongs to that company from the start. Still open: staff requests run with the tenant check bypassed (one tenant today), and every module's staff can read a company's chat thread in the Inbox (R110).

Internal use only — BreezyCorp