Skip to content

Day 6 — People: Employees, HR, Delivery KYC, Careers, Hiring

Version 1.0 | written against code 2026-09-16

Days 1 to 5 were about patients, orders, stock and money. Day 6 is about the people who work here, the people who deliver for us, and the people who want to join. It is the largest day by line count. internal/hiring is about 17k lines, internal/hr about 12k, internal/careers about 5k and internal/deliverykyc about 2.4k, plus the people half of internal/human. It is also the day with the most legal weight: Aadhaar numbers, bank accounts, CVs and pay slips all live here. Almost every design choice you will see comes from one question: what happens to this personal data, and who can see it?

Day 2 already covered login, OTP, JWT, refresh tokens and RBAC. Here we only point back to it.


Goals

By the end of today you can:

  1. Explain the three kinds of "person" rows in internal/human (users, employees, hospital operators), how role assignments tie them to permissions, and why a delivery partner is just an employee with a role.
  2. Trace an HR hire (POST /hr/api/v1/hires) from the request to the employees, role_assignments, employment, lifecycle-event and onboarding-task rows it writes.
  3. Explain where HR and rider KYC documents are stored, why no API returns a URL for them, and what the access logs record.
  4. Walk a rider through KYC: provision → upload → identity and bank submissions → review, and explain how Aadhaar is encrypted and checked for duplicates.
  5. Trace a public careers application through Turnstile, consent checks, the PDF check, R2 and the database trigger that puts it on the hiring board.
  6. Draw the hiring pipeline and offer state machines, and explain why "Hired" can never be reached by dragging a card.
  7. Explain how Google Calendar sync works (service account, worker pool, health refresher) and why it can fail without breaking an interview.
  8. Explain the erasure and retention paths for candidates, and name the gaps where rider and employee data has no matching path.

Reading order

#FileWhat to look for
1medyzen-backend/internal/human/module.goWhich services the People modules borrow from human (Access, Employee)
2medyzen-backend/internal/human/models/employee.go, employee_pii.gogorm:"-" plaintext fields, ciphertext columns, the encrypt/decrypt hooks
3medyzen-backend/internal/human/models/role_assignment.go, services/access.goResolveDeliveryPartner and resolveKYCGate (lines 30–82)
4medyzen-backend/cmd/server/main.go lines 195–240, 395–520, 736–790, 1095+Buckets, the KYC cipher, how each module is built, background workers, hrAccountControl
5medyzen-backend/internal/hr/routes/routes.goThe shape of HR: hires, lifecycle, documents, leave, attendance, payroll, exit
6medyzen-backend/internal/hr/services/hire.goHireEmployee: one transaction, "created" vs "adopted"
7medyzen-backend/internal/hr/models/document.go, services/document.goDocument kinds, restricted kinds, UploadDocument, DocumentContent (line 273)
8medyzen-backend/internal/human/common/permissions.goWhat hr_manager gets, and the two things it doesn't
9medyzen-backend/internal/deliverykyc/module.go, models/kyc.goTwo keys, two tracks, the three access-log tables
10medyzen-backend/internal/deliverykyc/services/kyc.goUploadDocument (128), SubmitIdentity (208), Provision (476), IdentityReview (628), reviewAction (767), DocumentContent (945)
11medyzen-backend/internal/careers/module.go, routes/careers.goPublic routes vs admin routes, rate limiter behind a proxy, retention sweeper wiring
12medyzen-backend/internal/careers/services/application_validation.goknownCareersNoticeVersions, rules for the consent value
13medyzen-backend/internal/careers/services/application.goSubmitApplication (283), Hire (667), PurgeApplication (766), assertHiringTablesEmpty (978)
14medyzen-backend/internal/careers/services/retention_sweeper.go, cmd/erase-careers-application/main.goThe only erasure code path and its three callers
15medyzen-backend/internal/database/migrations/259263, 268, 277Hiring roles, schema, stage seed, the pipeline trigger, the calendar switch to a service account, the grant for the hire route
16medyzen-backend/internal/hiring/module.go, routes/hiring.goHow the whole ATS is wired, the two public token routes
17medyzen-backend/internal/hiring/services/ports.go, scope.goRoleResolver, CareersApplicationPort, resolveScope (line 80)
18medyzen-backend/internal/hiring/services/candidate.goMoveStage (381), Hire (508)
19medyzen-backend/internal/hiring/models/offer.go, services/offer.goOfferStatusTransitions, transition (267), Respond (233)
20medyzen-backend/internal/hiring/services/calendar_sync.go, calendar_health_refresher.go, googlecalendar/serviceaccount.goWorker pool, fail-soft sync, token minting
21medyzen-backend/internal/hiring/services/application_notifier.go, erase.goOfferAccepted (186), the erase route with its passphrase step-up
22medyzen-backend/cmd/migrate-employee-kyc-storage/main.goA one-time move from a public bucket to a private one

Explanation

1. The people half of internal/human

internal/human holds three separate kinds of account, each in its own table:

ModelTableWhoLogs in with (Day 2)
UserusersPatientsPhone OTP
EmployeeemployeesStaff: super admin, warehouse staff, HR, recruiters, ridersEmail OTP / staff phone OTP
HospitalOperatorhospital_operatorsHospital partner staffEmail OTP

PatientAddress (patient_addresses) belongs to a user. Its routes are /human/api/v1/addresses*, wrapped in an app-only guard (internal/human/routes/patient_address.go). Look at the model and you see the pattern of this whole codebase: Line1, Line2, Locality, RecipientName, RecipientPhone and Landmark are all gorm:"-". They exist only in memory. The GORM hooks in patient_address_pii.go seal them into ciphertext columns. City, State and Pincode stay plain because delivery routing needs to query them.

Roles are not a column. A role is a row in role_assignments (subject_document_id, entity_type, role_id, assigned_by). One person can hold several roles. Day 2's PermissionStore unions all of them. Two things follow:

  • A delivery partner is an Employee with the delivery_partner role. There is no rider table. AccessService.ResolveDeliveryPartner (internal/human/services/access.go:54) loads the employee, lists role names, checks for common.RoleDeliveryPartner, then calls resolveKYCGate (line 30). That function runs raw SQL against delivery_partner_kyc and returns KYCApproved only when both identity_status and bank_status are approved. So human reads a table owned by deliverykyc without importing that package.
  • The hiring module never trusts actor.Role. The token carries a single "highest priority" role. internal/hiring/services/ports.go says outright that branching on it is a bug that only shows up for a person with two roles. Hiring resolves roles from role_assignments via RoleResolver instead.

The employee model (internal/human/models/employee.go) is where you first meet Aadhaar:

  • AadhaarNumber and BankAccountNumber are gorm:"-" and json:"-". The real columns are aadhaar_number_ciphertext and bank_account_number_ciphertext.
  • BeforeSave / AfterFind in employee_pii.go encrypt and decrypt through a package-level cipher. cmd/server/main.go installs that cipher around line 222 from KYC_ENCRYPTION_KEY, before any module runs a query.
  • The model comment explains the order the rollout had to follow: backfill, confirm "remaining unencrypted: 0", unmap the plaintext columns, and only then run the manual drop in ops/pending-migrations/252.
  • AadhaarFrontURL, AadhaarBackURL and CancelledChequeURL are legacy URL columns (json:"-"). The newer references are AadhaarFrontDocID, AadhaarBackDocID and CancelledChequeDocID, which point to document rows (migrations 159/160).

Two methods on EmployeeService matter for today:

  • ProvisionForHire (internal/human/services/employee.go:114) creates an employee row and a staff code, and nothing else. HR calls it through a port.
  • SetActive turns an account on or off. HR calls it through the same port.

2. How the People modules are built

Points from cmd/server/main.go to keep in mind:

  • Imports only go one way. internal/hiring imports internal/careers, never the reverse. Careers declares the ApplicationNotifier interface, and main.go calls careersMod.Application.SetApplicationNotifier(hiringMod.ApplicationNotifier) once both modules exist.
  • Each feature degrades on its own when config is missing. If R2_KYC_BUCKET_NAME is unset, HR document upload and download fail at request time, with a warning at boot. If R2_CAREERS_BUCKET_NAME is unset, the apply form returns 503. If TURNSTILE_SECRET_KEY is unset, captcha is off (warning). If HIRING_CALENDAR_SA_JSON is unset, the calendar feature is off and interviews use .ics files only. If CAREERS_RETENTION_SWEEP_ENABLED is false, the sweeper is never built.
  • deliverykyc.NewModule returns an error and the server calls Fatal if KYC_ENCRYPTION_KEY or KYC_BLIND_INDEX_KEY is missing or not a valid hex key, or if the two keys are equal. So you cannot boot the server locally without both keys.

3. internal/hr — the employee lifecycle

HR is a single Service (internal/hr/services) behind about 85 routes (internal/hr/routes/routes.go). You don't need to read all of them. The areas are:

AreaRoutes (prefix /hr/api/v1)Key model / states
Hire and invitePOST /hires, POST /employees/{docId}/inviteEmployment
LifecycleGET/POST /employees/{docId}/lifecycleonboarding → probation → active → notice_period → exited (and exited → active for rehire), models/employment.go:90
Compensation/employees/{docId}/compensationhistory rows
Onboarding checklist/employees/{docId}/onboarding, /my/onboarding, commentstasks pending / in_progress / done / not_applicable
Documents/employees/{docId}/documents*, /my/documents*, review queue, expiringpending / approved / rejected
Leave/leave/policies, /leave/requests, /my/leavecasual / sick / earned / unpaid
Attendance and holidays/attendance*, `/my/attendance/check-incheck-out, /holidays`
Payroll/payroll/runs*, /payroll/adjustments, payslip PDFsruns draft / pending / approved / paid / voided
Exit and settlement/exits, /employees/{docId}/exit, /settlement*task categories handover/assets/access/finance/paperwork
Directory and notifications/directory*, /notifications*, /my/notifications

Permissions work at two layers. Route RBAC (Day 2) decides whether you reach the handler. Inside the service, common.HasCapabilityAs(entityType, role, cap) checks a finer capability. internal/human/common/permissions.go gives hr_manager almost everything in HR, with two deliberate exceptions: it has no hr:document:restricted (Aadhaar front and back, PAN, cancelled cheque), and on payroll it can read and run but not approve or pay. The comment gives the reason: "the person computing a payroll is never the person releasing the money." super_admin passes every capability check.

The hire flow

Service.HireEmployee (internal/hr/services/hire.go):

  1. Refuses unless actor.Role == super_admin. HR managers cannot hire. Hiring creates an account and grants a login role, which the code treats as a super-admin decision.
  2. Validates employment type, a probation of 0–24 months, the joining date, and the role (ValidHireRole; super_admin is refused).
  3. In one transaction:
    • It calls accounts.FindByEmailFold. If no account exists, Provision creates one (mode created). If one exists, it is adopted (mode adopted), which is refused when the account is deactivated.
    • It checks the reporting manager (exists, and is not the new hire).
    • It refuses a second employment record for the same person.
    • It calls NextEmployeeCode and AssignRoleIfUnassigned. If the account already holds a different role, the result is a 409.
    • It writes Employment with lifecycle onboarding, or probation when probation months > 0.
    • It appends an employment_created lifecycle event.
    • It seeds DefaultOnboardingTemplate tasks, due 7 days after joining.
  4. After the transaction commits, it sends the invite email, best effort. The response always says invite.sent and gives a reason (not_requested, mailer_not_configured, send_failed), so the panel can show a hire that worked but whose email did not.

Unique-violation errors are sorted into "email taken" or "employment exists" by index name (classifyHireUniqueViolation).

The lifecycle that the hire starts is a small state machine, stored as data in internal/hr/models/employment.go:90:

Each transition goes through POST /hr/api/v1/employees/{docId}/lifecycle and appends a row to the lifecycle event log, which GET …/lifecycle returns as a timeline. Exit tasks and final settlement (/exit, /settlement) are separate checklists with their own statuses. Read services/exit.go if you need them.

HR documents

UploadDocument (internal/hr/services/document.go) checks the capability, the kind against ValidDocumentKinds, a content type of JPEG/PNG/PDF only, and a size of at most 15 MB. It then:

  • builds the object key hr/<employeeID>/<kind>-<unixnano>-<sanitized filename>,
  • uploads to storage first, then in a transaction marks the current document of that kind as superseded and inserts the new row,
  • deletes the uploaded object if the database write fails.

DocumentContent (line 273) streams the bytes through the API. There is no presigned URL and no public URL. Before any bytes leave, it:

  • lets the employee read their own documents,
  • requires hr:document:read for anyone else, plus hr:document:restricted for Aadhaar front/back, PAN or cancelled cheque,
  • writes an employee_document_access_log row, and fails the request if that write fails.

Where do these files live? main.go (around line 398): "HR employee documents share the private KYC bucket." hrDocStorage is the same kycR2 client that rider KYC uses (R2_KYC_BUCKET_NAME). HR files go under hr/…, rider files under kyc/….

4. internal/deliverykyc — rider KYC

Routes (internal/deliverykyc/routes/kyc.go) sit under /human/api/v1/… even though a separate module owns them:

  • Rider self-service: POST /delivery-partners/kyc/documents (with an upload limiter keyed on the actor), POST …/kyc/identity, POST …/kyc/bank, GET …/kyc/status.
  • Warehouse / admin: POST /delivery-partners/provision, identity and bank queues, GET …/{docId}/identity, …/bank, …/summary, approve/reject for each track, …/documents/{documentId}/content, GET/PUT …/config.

Each track (identity, bank) runs the same small machine, enforced in SubmitIdentity / SubmitBank (refuse while pending) and reviewAction (refuse unless pending):

Past the cap, a submission returns 429 (ErrSubmissionCapReached). The identity check is at kyc.go:~248. Submit only refuses pending, so as the code reads, an already-approved rider can resubmit and drop back to pending. The re-KYC deadline flow depends on that.

Every submit and every review writes a delivery_partner_kyc_submissions row (attempt number, outcome, rejected fields). That table is the history the review screen shows.

Details worth knowing:

  • Two independent tracks. Identity and bank each have their own status (not_started / pending / approved / rejected), submission count, rejected-field categories and note. A rider can be assigned orders only when both are approved (see resolveKYCGate). Resubmitting while pending returns 409. reviewAction returns 409 if the track is not pending, so a reviewer cannot approve past a missing submission.
  • Who reviews what. Identity review is scoped by warehouse (access.Scope). Bank approve/reject requires super_admin (reviewAction, lines ~773–784). For document content, cancelled_cheque is super-admin only, and other kinds are warehouse-scoped.
  • Aadhaar handling (SubmitIdentity, line 208):
    • The DTO requires exactly 12 digits (validate:"required,len=12,numeric"). There is no checksum check in this path (not verified beyond the DTO tag).
    • The number is encrypted with AES-GCM (crypto.AEAD) into aadhaar_number_ciphertext, and an HMAC-style blind index goes into aadhaar_number_bidx. PAN, bank account and UPI get the same treatment.
    • Duplicate detection runs on the blind index. Migration 172 line 163: CREATE UNIQUE INDEX … ON delivery_partner_kyc(aadhaar_number_bidx) WHERE … identity_status <> 'rejected'. A second rider with the same Aadhaar gets a 409 "already registered to another delivery partner", not a 500.
    • The encryption key and the blind-index key must differ. NewModule refuses equal keys.
  • Reviewers see the full number. IdentityReview decrypts Aadhaar and PAN and returns them unmasked. Every such read writes delivery_partner_kyc_identity_access_log with the fields revealed (migration 248). Note the asymmetry the comment calls deliberate: if that audit write fails, the read still goes ahead (logged as an error). The document-content path fails closed when its audit write fails.
  • Resubmission caps live in delivery_partner_kyc_config (max_identity_submissions, max_bank_submissions, default 5), along with a rekyc_deadline_at. Warehouse managers can read the config. Only super admin can update it.
  • Content types: JPEG, PNG, WebP, PDF. The 15 MB limit is checked against the size the client declares (in.Size). Whether the handler also caps the body bytes is not verified in this lesson.

5. internal/careers — public openings and applications

Nine routes (internal/careers/routes/careers.go):

  • Public, no auth: GET /careers/api/v1/openings, GET /careers/api/v1/openings/{slug}, POST /careers/api/v1/applications (IP rate limit only).
  • Admin, auth-wrapped: openings CRUD; application list, export, detail, status, resume, hire. These routes do no role check of their own. Route RBAC alone guards them (granted to super_admin by migration 258).

The route file comment carries a trap you will hit: public routes must also be listed in publicPatterns in internal/human/services/endpoint_sync.go. If they are missing they "all 401 in prod while every local test passes."

The rate limiter behind a proxy

The landing page is a Next.js app that forwards the form to the API, so the API sees the proxy's IP, not the candidate's. careers/module.go builds middleware.NewProxyAwareRateLimiter with:

  • its own Redis prefix rl:careers-ip:, so a flood on careers cannot 429 the contact form,
  • a trusted header X-Medyzen-Client-IP, which is honored only if the connecting address is inside CAREERS_TRUSTED_PROXY_CIDRS or the request carries the shared TRUSTED_PROXY_SECRET. The two are joined by OR because Amplify has no stable egress IP.
  • With both unset, the header is never trusted and every candidate shares one bucket. That is safe but coarse, and main.go logs a warning.

SubmitApplication step by step (application.go:283)

  1. It normalizes and validates every field: name, email (net/mail), phone (a 10-digit Indian mobile number or E.164, becoming +91…), https-only URLs, experience 0–60 with one decimal place, CTC ≥ 0, employment preference, UTM fields.
  2. Consent: consent_accepted must be the literal string "true". "1", "on" and "True" all fail. consent_notice_version must match ^careers-notice-\d{4}-\d{2}$ and be in knownCareersNoticeVersions (today only careers-notice-2026-09). talent_pool_consent never blocks a submission.
  3. Turnstile: captcha.Verify fails closed. A missing or failed token returns a 4xx error. If Cloudflare is unreachable, the result is an "unavailable" error. Skipped only when the verifier is nil (secret unset).
  4. Resume required, at most 5 MB (MaxResumeBytes). Storage must be configured.
  5. Per-email limiter, keyed on a hash of the email so no raw email sits in Redis beyond the reach of erasure. It runs before any DB or R2 call.
  6. Looks up a published opening by slug.
  7. sniffAndStitchPDF checks the real bytes with http.DetectContentType, not the client's header, and only PDFs pass. The filename is sanitized and forced to end in .pdf.
  8. Uploads to careers/<openingDocID>/<applicationID>.pdf. The object key is json:"-" and never appears in any DTO, CSV or log.
  9. Inserts the row, with ConsentAcceptedAt = AppliedAt = CreatedAt = now truncated to microseconds (the Postgres timestamp trap), lawful_basis=consent and ingest_source=form. On failure it deletes the object. A unique violation means a duplicate application.
  10. Best effort: notifier.ApplicationReceived notifies recruiters and that opening's members.

A second write path exists: ImportApplications (line 1135), used by hiring's LinkedIn CSV import. Those rows are stored with lawful_basis=voluntary_provision and no consent columns. Rows already older than 12 months are refused (refused_stale). An email on the erasure suppression list is skipped (skipped_erased).

The database trigger that feeds hiring

Migration 263 adds AFTER INSERT ON job_applications. The trigger finds the non-archived stage whose maps_to_status equals the new row's status (lowest sort_order, then id) and inserts hiring_pipeline_state. So every application appears on the hiring board automatically, whichever write path created it. Migration 262 seeded the six default stages and backfilled existing rows. It raises an error if the counts don't match.

6. internal/hiring — the ATS

Hiring is big, but its structure is regular. Each area has a handler, a service and a repository:

AreaRoutes (prefix /hiring/api/v1)Service
IdentityGET /meIdentityService
Openings and membership/openings*, /openings/{docId}/membersOpeningService
Pipeline stages/stages*StageService
Candidates/candidates, /board, /search, /{docId}, /stage, /hire, /resume, /activityCandidateService
Notes, tags/candidates/{docId}/notes, /notes/{docId}*, /tags*NoteService, TagService
Interview kits, interviews, scorecards/kits*, /candidates/{docId}/interviews, /interviews/{docId}*, /my/interviews*KitService, InterviewService, MyInterviewService
Calendar admin/admin/calendar/status, /admin/calendar/verifyCalendarAdminService
Import, analytics/imports/candidates*, /analytics/*ImportService, AnalyticsService
Candidate email/candidates/{docId}/emails*, /openings/{docId}/emails/bulk*EmailService
Offers/candidates/{docId}/offers, /offers/{docId}*OfferService
Candidate self-service (public, token)/public/preferences/{token}, /withdrawCandidateTokenService
Erasure/erasure/unlock, /candidates/{docId}/erasure-preview, /erase, /legal-holdEraseService

The tables (migration 260, all prefixed hiring_) hang off job_applications, which careers owns:

Every table with a direct application_id uses ON DELETE CASCADE. Panelists, scorecards and ratings cascade transitively through interviews. The erasure path in section 8 depends on this. hiring_calendar_health (one row) is not tied to any candidate.

Roles (migration 259) are recruiter, hiring_manager and interviewer, all entity_type=employee. hr_manager and super_admin also appear. Scope comes from resolveScope (scope.go:80):

  • super_admin, recruiter and hr_manager see every opening.
  • hiring_manager sees only openings where they are an explicit member (hiring_opening_members). Never by department.
  • Anyone else, including interviewer-only staff, gets an empty scope. That fails closed. Interviewers work only through /my/interviews*.

Every service method first resolves scope, then passes scope.ScopedOpeningIDs into the repository query. A candidate outside your scope returns "not found", not "forbidden".

The rule for careers writes. CareersApplicationPort (ports.go) is the only way hiring writes job_applications: UpdateStatus, Hire, ImportApplications, MarkNoticeSent, SetTalentPoolConsent, PurgeApplication, SetLegalHold. Careers uses RunInTxJoining. When hiring opens a transaction and calls the port inside it, careers joins that transaction, so the careers status and hiring's hiring_pipeline_state commit or roll back together. Lock order is fixed: job_applications first (via careers), then hiring_pipeline_state.

Pipeline state diagram

Stages can be configured (create, rename, reorder, archive), but every stage maps to one of six fixed job_applications.status values. The diagram below uses the seeded stages.

The diagram doesn't show every edge. MoveStage allows any non-archived target stage. Only two rules are enforced:

  1. No drag into a stage that maps to hired (EntersHiredMirrorerrStageHiredCannotBeEnteredByDrag, candidate.go:~408). Entering hired claims an employment relationship exists, and a drag cannot know that.
  2. Hired is terminal (careers UpdateStatus: once hired, any other status is refused. Also, hired needs hired_employee_id set first).

MoveStage to a rejected stage also cancels scheduled interviews in the same transaction. Then, after the commit, it releases the Google Calendar holds. Network calls stay outside the transaction on purpose, so a Google outage cannot block a rejection.

CandidateService.Hire (line 508) checks that the employee exists, picks the hired stage deterministically (lowest sort_order, then id, the same tiebreak as the trigger), and in one transaction calls careers.Hire (sets status=hired and hired_employee_id, refuses a second hire) then moves the pipeline state. Migration 277 grants this route to recruiter only. hiring_manager is excluded on purpose, and super_admin gets in through the RBAC bypass.

Offers

The machine is data (models.OfferStatusTransitions). OfferService.transition (line 267) is "the ONE place a status moves". It checks scope, re-reads the offer FOR UPDATE inside the transaction, asks CanTransitionOffer, mutates, and writes an activity row. Money fields are decimal.Decimal. Not wired: Respond only accepts accepted|declined (dtos/offer.go:116), and no code path or worker found moves an offer to expired. The state exists in the machine, and careers counts it as "not live", but nothing sets it today.

Google Calendar

Migration 265 first shipped per-admin OAuth. Migration 268 replaced it with a domain-wide-delegation service account, dropped the OAuth state table, and renamed the connections table to …_retired_268. Today:

  • HIRING_CALENDAR_SA_JSON (a secret) is parsed once at boot (googlecalendar.ParseServiceAccountKey). A bad key fails the boot. If the key is set but HIRING_CALENDAR_SUBJECT is empty, boot also fails.
  • ServiceAccountTokenSource signs a JWT and exchanges it at Google's token endpoint for the single scope calendar.events, impersonating the subject mailbox. Tokens are cached, with a 2-minute skew.
  • CalendarTokenService wraps the token source and writes the one-row hiring_calendar_health table (connected / expired / not_connected, last error).
  • CalendarHealthRefresher ticks every 30 minutes (module.go). It asks for a valid token purely for the side effect: a missing delegation grant only surfaces on a real mint, so the stored status stays honest between interviews.
  • CalendarSyncService is a bounded worker pool: 4 workers (calendarSyncPoolSize), a queue of 64 (calendarSyncQueueCapacity). InterviewService calls AfterScheduled / AfterRescheduled / AfterCancelled, and each call only enqueues. If the queue is full, the job is dropped, a metric is incremented, and a warning is logged. Every job runs with its own 20-second timeout on context.Background(), never the request context, and recovers from panics.
  • The pool runs on poolCtx, not reconcilerCtx. main.go explains why: the pool has to keep draining queued jobs during the HTTP shutdown drain. Only its Done() channel is added to the worker wait set.
  • Fail-soft: the interview row is correct either way. If sync fails, the recruiter still has the .ics download (GET /interviews/{docId}/ics).
  • Privacy gate inside sync: syncCreate calls noticeOwed(lawfulBasis, noticeSentAt). For an imported (voluntary_provision) candidate who has not yet been sent the privacy notice, the candidate is left off the invite and a calendar_notice_withheld activity is written to their timeline. The same gate in EmailService only allows the ingest_notice template for those rows.

Stale comment alert: main.go around line 740 still says the refresher is nil "when GOOGLE_CALENDAR_CLIENT_ID/SECRET/REDIRECT_URI are unset". It is actually nil when the service-account key is missing (hiring/module.go). .env.example still lists HIRING_CALENDAR_ENCRYPTION_KEY from the retired OAuth path. No Go code reads it.

7. From application to employee: the handoff

The important part is the dashed human gap between I and J. Hiring never creates an employee. OfferAccepted (application_notifier.go:186) only writes notifications. The notification body says an accepted offer "is ready to be turned into an employee record". Account creation stays on POST /hr/api/v1/hires, which requires super admin. Per team notes, the super-admin People page prefills the hire form from the offer and then links the application back as hired. That frontend behaviour was not verified in backend code. Role and compensation are never prefilled.

8. Retention and erasure

Candidates have a complete erasure path. Riders and employees do not.

ApplicationService.PurgeApplication (careers/services/application.go:766) is the only erasure code path. It has three callers:

  1. cmd/erase-careers-application: a manual operator tool. Without -commit it is a dry run. It prints a redacted DB target (host/db only) so you notice when your .env is pointing at prod.
  2. RetentionSweeper (retention_sweeper.go): runs every hour, 50 rows per tick, cutoff applied_at < now − 12 months. It is safe with several ECS tasks: a race shows up as not-found and is counted already_gone. It uses the fixed actor UUID …00c5. It is disabled by default in code and enabled in deploy/aws/variables.tf.
  3. POST /hiring/api/v1/candidates/{docId}/erase (hiring/services/erase.go:79): super_admin only (checked through RoleResolver). It needs confirm_document_id to equal the path id and a step-up unlock token from POST /erasure/unlock, which is checked against CAREERS_ERASE_PASSPHRASE_HASH. It is rate-limited to about 5 per hour per actor.

What happens inside, all in one transaction with the row locked FOR UPDATE:

  • Refuse if legal_hold, if status=hired (that would destroy the link to the employee), or if a live offer exists (pending_approval / approved / sent / accepted).
  • Delete the R2 resume before the row. The key is only knowable from the row, so the reverse order could strand a CV nobody could find.
  • Hard-delete the row (a GORM soft delete would only be "concealment from the operator, not erasure"). Every hiring_* child table cascades.
  • Insert sha256(lower(trim(email))) into careers_erasure_suppressions, so a later CSV import won't recreate the person. The public form is not blocked by it.
  • After commit, assertHiringTablesEmpty counts rows in 8 hiring tables and returns an error if any remain, in case a future migration forgot ON DELETE CASCADE.
  • The audit log line carries document_id, opening_id and actor only.

For riders, DeliveryPartnerAuthService.RequestDeletion only sets employees.deletion_requested_at. Migration 199 says the review and execution workflow "is explicitly NOT built". A search of internal/deliverykyc, internal/hr and internal/human found no retention sweeper or erasure routine for KYC rows, KYC documents or HR documents.

9. cmd/migrate-employee-kyc-storage

This one-time script belongs to internal/assets documents with category = "Employee KYC" (the older "Important Documents" module), not to HR's employee_documents. Those files used to go to R2_BUCKET_NAME. .env.example calls that bucket "world-readable". DocumentService.storageFor (internal/assets/services/document.go:113) now sends that category to the private restricted-docs bucket. The script moves the old objects:

  • -copy and -delete-source are mutually exclusive, so you can't delete on the strength of a copy made in the same run.
  • Both default to a dry run. -commit is required to write.
  • Copy verifies ETag and size with HeadObject. It refuses to overwrite a different object at the destination.
  • Delete-source re-checks that the destination matches before removing the only other copy.
  • No database rows change. The bucket is chosen from the category when the file is read.

10. Configuration quick reference

Names only, never values. Where each one is read is noted so you can find it again.

VariableRead inEffect when empty / false
R2_KYC_BUCKET_NAMEmain.gokycR2Rider KYC uploads return 503. HR document upload/read fail. Boot warning
KYC_ENCRYPTION_KEYmain.go (employee cipher), deliverykyc.NewModuleBoot fails (deliverykyc). Employee Aadhaar/bank numbers not sealed
KYC_BLIND_INDEX_KEYdeliverykyc.NewModuleBoot fails. Must differ from the encryption key
KYC_UPLOAD_RATE_LIMIT / _BURSTdeliverykyc/module.goDefaults to 0.2/s, burst 10, per actor
R2_CAREERS_BUCKET_NAMEmain.go → careers + hiring resume storageApply returns 503, resume download off
TURNSTILE_SECRET_KEYmain.go → careers captchaCaptcha off (warning; preflight should flag it)
CAREERS_TRUSTED_PROXY_CIDRS, TRUSTED_PROXY_SECRETcareers/module.goClient-IP header never trusted, one shared rate-limit bucket
CAREERS_RETENTION_SWEEP_ENABLEDcareers/module.goSweeper not built. Only manual erasure
CAREERS_ERASE_PASSPHRASE_HASH, CAREERS_ERASE_UNLOCK_MINUTEShiring/services/erase_unlock.goErase route refuses ("not configured"), never bypasses
HIRING_CALENDAR_SA_JSON, HIRING_CALENDAR_SUBJECTmain.gohiring.NewModuleCalendar off, .ics only. Key set without subject = boot fails
R2_RESTRICTED_DOCS_BUCKET_NAMEmain.go, migrate-employee-kyc-storageAssets "Employee KYC" downloads fail. The script refuses to run

Traps

  1. The careers notice is a closed list. knownCareersNoticeVersions (application_validation.go) is an allow-list of notice texts the landing page published. The published notice lists the fields it collects with no catch-all. A new apply field that isn't a refinement of a listed item needs a new dated notice version. The rules: add the new version, never replace the old one (old tabs and stored rows still need to validate), deploy the backend before the frontend (otherwise every submission 400s), and reject new fields on submissions that declare an older version. currentCareersNoticeVersion() picks the lexically largest version for MarkNoticeSent, so the naming format matters.
  2. Hiring must never create employees. OfferAccepted only notifies. POST /hr/api/v1/hires is super_admin only in code (hire.go), and the hiring roles cannot reach it. Auto-provisioning from an accepted offer needs explicit sign-off and a contract change first. Do not "streamline" it.
  3. Nobody reaches hired by drag. Don't add a status-only path to hired. Careers refuses it while hired_employee_id is null, and hired is terminal. Always write job_applications through CareersApplicationPort, never with raw SQL from hiring. Otherwise the board and careers disagree.
  4. Aadhaar in rider KYC is shown in full to reviewers. IdentityReview returns the decrypted Aadhaar and PAN. The only control after RBAC and warehouse scope is delivery_partner_kyc_identity_access_log, and that write is best effort. Don't add Aadhaar to list/queue DTOs, logs or exports. Keep KYC_ENCRYPTION_KEYKYC_BLIND_INDEX_KEY. The duplicate check depends on the blind index being stable, so rotating the blind-index key breaks duplicate detection for existing rows (inferred from the design, not tested).
  5. Employee Aadhaar with no cipher is silently dropped. Employee.AadhaarNumber is gorm:"-", and BeforeSave returns early when no cipher is registered. main.go warns "stay in plaintext", but the plaintext columns are no longer mapped, so as far as we can read the code the value is simply not stored (inferred from employee.go and employee_pii.go, not tested). Set a local KYC_ENCRYPTION_KEY whenever you test employee details.
  6. KYC buckets: private by design, but check the bucket, not just the code. R2Client.Upload returns no URL, and HR, rider KYC and careers all stream bytes through authenticated, audited handlers. Whether a bucket is actually private is a Cloudflare setting, and the code cannot tell you. Also: HR documents share the rider KYC bucket (kycR2). If that bucket is missing or outside the R2 token's scope, uploads 403 and rider onboarding plus HR paperwork both break. This happened once (see the r2_access_key_id comment in deploy/aws/variables.tf). That token also has an expiry date noted there.
  7. The legacy public bucket. R2_BUCKET_NAME is world-readable. The old employees.aadhaar_front_url and cancelled_cheque_url columns can still be written through applyEmployeeDetails (human/services/employee.go:339). Never point one at a public object. Use the *_doc_id references.
  8. DPDP retention gaps. Candidates have a 12-month sweep plus erasure. Riders and employees don't: deletion_requested_at is only a flag, and KYC and HR documents have no retention job. Before you add fields that collect more personal data, check with legal-compliance.
  9. Public routes need endpoint_sync.go. The three careers public routes and the two hiring token routes must be in publicPatterns, or prod returns 401 while tests pass. After an RBAC migration, every ECS task has to restart before grants take effect (migration 277 comment).
  10. Missing config is logged at boot, not caught at runtime. A missing Turnstile secret disables captcha. Missing trusted-proxy config puts every candidate in one rate-limit bucket. A missing careers bucket makes the apply form return 503. The only signal is a warning at boot, plus the preflight engine (Day 7).
  11. Scope by membership, never by actor.Role. A person with two roles breaks any hiring check that reads the token role. Use resolveScope. Note that HR's HireEmployee does check actor.Role. That is fine there only because super admin is the highest-priority role.
  12. Don't run erasure tools against a DB you haven't identified. config.Load() reads your .env, which may hold the prod connection string. erase-careers-application prints the target for exactly this reason. Read it.

Exercises

All against the local stack (make docker-up; Postgres is published on localhost:5434). Blank every real secret in your .env first. Use locally generated 32-byte hex keys for KYC_ENCRYPTION_KEY and KYC_BLIND_INDEX_KEY (two different values). Leave all R2_* blank unless you run a local S3-compatible store. Never use production credentials.

  1. Map the modules. In cmd/server/main.go, list every Warn a local boot prints for People features, and what each one disables. Then start the server and check your list against the actual log.
  2. Hire flow, via tests. Create a scratch database and run make test-integration TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5434/medyzen_test?sslmode=disable" limited to ./internal/hr/services/... (use go test -tags integration -run Hire ./internal/hr/services/). Read hire_integration_test.go and write down which test covers the "adopted" mode and which covers the 409 for a different role.
  3. Pipeline trigger. On your local DB, insert a job_openings row and a job_applications row by hand (minimal columns), then query hiring_pipeline_state. Archive the "New" stage (UPDATE hiring_stages SET archived = TRUE WHERE name='New'), insert another application, and explain what the trigger did. Restore the stage afterwards.
  4. Offer machine. Without running anything, use OfferStatusTransitions to list every path from draft to a terminal state. Then grep for any code that can produce expired, and write one paragraph on what a correct expiry job would need (locking, activity rows, whether careers' "live offer" check changes).
  5. Erasure guarantees. Run go test -tags integration -run PurgeApplication ./internal/careers/services/ against the scratch DB. Read application_hiring_erasure_integration_test.go. Then write a migration on a throwaway branch that adds a new hiring table with application_id … ON DELETE SET NULL and confirm which assertion would catch it. Do not commit.
  6. Notice version change (paper exercise). Suppose the form gains "notice period in days". List every backend change needed (allow-list entry, a version → allowed-fields map, the DTO, validation, migration, the column's nullability meaning "not asked") and the deploy order.
  7. Rider KYC duplicate. With the local server running and local keys set, provision two riders (as a local super admin seeded with make seed-admin EMAIL=…), submit identity for both with the same made-up 12-digit number, and confirm the second gets 409. Uploads need R2. If R2 is blank, write down the error you get at the document step and which line returns it.
  8. Calendar pool. Read calendar_sync.go and answer: if 100 interviews are scheduled in one second while Google is down, how many sync jobs run, how many are dropped, what metric moves, and what the recruiter sees on each interview.

Self-check

  1. Where is a delivery partner stored, and what two conditions make KYCApproved true?
  2. Why does HireEmployee have a "created" and an "adopted" mode, and when is adoption refused?
  3. Which two capabilities does hr_manager deliberately lack, and why?
  4. How does an HR document get from R2 to a browser, and what must succeed before any bytes are sent?
  5. How does rider KYC detect a duplicate Aadhaar without storing it in plaintext, and what does a rejected record do to that check?
  6. What exactly must consent_accepted and consent_notice_version contain for a careers application to be accepted?
  7. What puts a new application on the hiring board, and what happens if no active stage maps to its status?
  8. Why can't a recruiter drag a candidate into "Hired", and what does the hire endpoint require instead?
  9. Name the three callers of PurgeApplication and the three conditions under which it refuses.
  10. What happens to an interview when Google Calendar is down or the sync queue is full?
  11. Why is the imported candidate sometimes left off a calendar invite?
  12. What does cmd/migrate-employee-kyc-storage move, from where to where, and why are -copy and -delete-source separate runs?

Answers

  1. In employees, with a delivery_partner row in role_assignments. There is no separate rider table. resolveKYCGate requires delivery_partner_kyc.identity_status = 'approved' and bank_status = 'approved' (human/services/access.go:30).
  2. HR can hire someone who already has a staff account (for example, an earlier login), found by case-insensitive email. Otherwise it provisions a new account. Adoption is refused when the existing account is deactivated. The hire also fails if that account already has an employment record, or already holds a different role.
  3. hr:document:restricted (Aadhaar front/back, PAN, cancelled cheque need a second decision) and payroll approve/pay. HR managers may preview and run payroll, but the person computing pay must not release it.
  4. It is streamed through GET /hr/api/v1/employees/{docId}/documents/{documentId}/content. No URL is ever issued. The service checks ownership or hr:document:read (plus hr:document:restricted for restricted kinds), then writes employee_document_access_log. If that write fails, the request fails.
  5. It stores a keyed blind index of the normalized number (aadhaar_number_bidx) alongside AES-GCM ciphertext. A partial unique index on the blind index excludes rows where identity_status = 'rejected', so a rejected record frees the number for a corrected submission. A clash returns 409.
  6. consent_accepted must be exactly the string "true". consent_notice_version must match careers-notice-YYYY-MM and be a key in knownCareersNoticeVersions (currently only careers-notice-2026-09).
  7. Trigger trg_hiring_pipeline_state_on_application_insert (migration 263) inserts hiring_pipeline_state for the lowest-sort, non-archived stage with a matching maps_to_status. If none matches, the trigger returns without inserting, so the application has no pipeline row.
  8. Entering hired claims an employment relationship exists, so MoveStage refuses any stage that maps to hired. POST /hiring/api/v1/candidates/{docId}/hire requires an existing employee_id. In one transaction it sets hired_employee_id and status=hired through careers and moves the pipeline to the deterministic hired stage. It is granted to recruiter, and super admin gets in through the bypass.
  9. Callers: cmd/erase-careers-application, RetentionSweeper, and POST /hiring/api/v1/candidates/{docId}/erase (super admin + passphrase unlock). It refuses on legal_hold, status = hired, or a live offer (pending_approval/approved/sent/accepted).
  10. Nothing breaks. The interview row is already committed. Sync is enqueued to a 4-worker pool with a 64-slot queue. A full queue drops the job (metric plus warning), and a failed call marks sync failed and logs it. The recruiter can still use the .ics download.
  11. noticeOwed is true for a voluntary_provision (imported) candidate with no notice_sent_at. Until the DPDP notice email has gone out, the invite goes only to panelists, and a calendar_notice_withheld activity is recorded.
  12. It moves old internal/assets documents with category "Employee KYC" from the world-readable R2_BUCKET_NAME bucket into R2_RESTRICTED_DOCS_BUCKET_NAME, under the same key. The runs are separate so that a source object is only deleted after a later, independent check that the destination matches by ETag and size, never on the strength of a copy the same run just made.

Internal — written against the code, not the other way around.