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:
- 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. - Trace an HR hire (
POST /hr/api/v1/hires) from the request to theemployees,role_assignments, employment, lifecycle-event and onboarding-task rows it writes. - Explain where HR and rider KYC documents are stored, why no API returns a URL for them, and what the access logs record.
- Walk a rider through KYC: provision → upload → identity and bank submissions → review, and explain how Aadhaar is encrypted and checked for duplicates.
- Trace a public careers application through Turnstile, consent checks, the PDF check, R2 and the database trigger that puts it on the hiring board.
- Draw the hiring pipeline and offer state machines, and explain why "Hired" can never be reached by dragging a card.
- Explain how Google Calendar sync works (service account, worker pool, health refresher) and why it can fail without breaking an interview.
- Explain the erasure and retention paths for candidates, and name the gaps where rider and employee data has no matching path.
Reading order
| # | File | What to look for |
|---|---|---|
| 1 | medyzen-backend/internal/human/module.go | Which services the People modules borrow from human (Access, Employee) |
| 2 | medyzen-backend/internal/human/models/employee.go, employee_pii.go | gorm:"-" plaintext fields, ciphertext columns, the encrypt/decrypt hooks |
| 3 | medyzen-backend/internal/human/models/role_assignment.go, services/access.go | ResolveDeliveryPartner and resolveKYCGate (lines 30–82) |
| 4 | medyzen-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 |
| 5 | medyzen-backend/internal/hr/routes/routes.go | The shape of HR: hires, lifecycle, documents, leave, attendance, payroll, exit |
| 6 | medyzen-backend/internal/hr/services/hire.go | HireEmployee: one transaction, "created" vs "adopted" |
| 7 | medyzen-backend/internal/hr/models/document.go, services/document.go | Document kinds, restricted kinds, UploadDocument, DocumentContent (line 273) |
| 8 | medyzen-backend/internal/human/common/permissions.go | What hr_manager gets, and the two things it doesn't |
| 9 | medyzen-backend/internal/deliverykyc/module.go, models/kyc.go | Two keys, two tracks, the three access-log tables |
| 10 | medyzen-backend/internal/deliverykyc/services/kyc.go | UploadDocument (128), SubmitIdentity (208), Provision (476), IdentityReview (628), reviewAction (767), DocumentContent (945) |
| 11 | medyzen-backend/internal/careers/module.go, routes/careers.go | Public routes vs admin routes, rate limiter behind a proxy, retention sweeper wiring |
| 12 | medyzen-backend/internal/careers/services/application_validation.go | knownCareersNoticeVersions, rules for the consent value |
| 13 | medyzen-backend/internal/careers/services/application.go | SubmitApplication (283), Hire (667), PurgeApplication (766), assertHiringTablesEmpty (978) |
| 14 | medyzen-backend/internal/careers/services/retention_sweeper.go, cmd/erase-careers-application/main.go | The only erasure code path and its three callers |
| 15 | medyzen-backend/internal/database/migrations/259–263, 268, 277 | Hiring roles, schema, stage seed, the pipeline trigger, the calendar switch to a service account, the grant for the hire route |
| 16 | medyzen-backend/internal/hiring/module.go, routes/hiring.go | How the whole ATS is wired, the two public token routes |
| 17 | medyzen-backend/internal/hiring/services/ports.go, scope.go | RoleResolver, CareersApplicationPort, resolveScope (line 80) |
| 18 | medyzen-backend/internal/hiring/services/candidate.go | MoveStage (381), Hire (508) |
| 19 | medyzen-backend/internal/hiring/models/offer.go, services/offer.go | OfferStatusTransitions, transition (267), Respond (233) |
| 20 | medyzen-backend/internal/hiring/services/calendar_sync.go, calendar_health_refresher.go, googlecalendar/serviceaccount.go | Worker pool, fail-soft sync, token minting |
| 21 | medyzen-backend/internal/hiring/services/application_notifier.go, erase.go | OfferAccepted (186), the erase route with its passphrase step-up |
| 22 | medyzen-backend/cmd/migrate-employee-kyc-storage/main.go | A 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:
| Model | Table | Who | Logs in with (Day 2) |
|---|---|---|---|
User | users | Patients | Phone OTP |
Employee | employees | Staff: super admin, warehouse staff, HR, recruiters, riders | Email OTP / staff phone OTP |
HospitalOperator | hospital_operators | Hospital partner staff | Email 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
Employeewith thedelivery_partnerrole. There is no rider table.AccessService.ResolveDeliveryPartner(internal/human/services/access.go:54) loads the employee, lists role names, checks forcommon.RoleDeliveryPartner, then callsresolveKYCGate(line 30). That function runs raw SQL againstdelivery_partner_kycand returnsKYCApprovedonly when bothidentity_statusandbank_statusareapproved. Sohumanreads a table owned bydeliverykycwithout importing that package. - The hiring module never trusts
actor.Role. The token carries a single "highest priority" role.internal/hiring/services/ports.gosays outright that branching on it is a bug that only shows up for a person with two roles. Hiring resolves roles fromrole_assignmentsviaRoleResolverinstead.
The employee model (internal/human/models/employee.go) is where you first meet Aadhaar:
AadhaarNumberandBankAccountNumberaregorm:"-"andjson:"-". The real columns areaadhaar_number_ciphertextandbank_account_number_ciphertext.BeforeSave/AfterFindinemployee_pii.goencrypt and decrypt through a package-level cipher.cmd/server/main.goinstalls that cipher around line 222 fromKYC_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,AadhaarBackURLandCancelledChequeURLare legacy URL columns (json:"-"). The newer references areAadhaarFrontDocID,AadhaarBackDocIDandCancelledChequeDocID, 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.SetActiveturns 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/hiringimportsinternal/careers, never the reverse. Careers declares theApplicationNotifierinterface, andmain.gocallscareersMod.Application.SetApplicationNotifier(hiringMod.ApplicationNotifier)once both modules exist. - Each feature degrades on its own when config is missing. If
R2_KYC_BUCKET_NAMEis unset, HR document upload and download fail at request time, with a warning at boot. IfR2_CAREERS_BUCKET_NAMEis unset, the apply form returns 503. IfTURNSTILE_SECRET_KEYis unset, captcha is off (warning). IfHIRING_CALENDAR_SA_JSONis unset, the calendar feature is off and interviews use.icsfiles only. IfCAREERS_RETENTION_SWEEP_ENABLEDis false, the sweeper is never built. deliverykyc.NewModulereturns an error and the server callsFatalifKYC_ENCRYPTION_KEYorKYC_BLIND_INDEX_KEYis 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:
| Area | Routes (prefix /hr/api/v1) | Key model / states |
|---|---|---|
| Hire and invite | POST /hires, POST /employees/{docId}/invite | Employment |
| Lifecycle | GET/POST /employees/{docId}/lifecycle | onboarding → probation → active → notice_period → exited (and exited → active for rehire), models/employment.go:90 |
| Compensation | /employees/{docId}/compensation | history rows |
| Onboarding checklist | /employees/{docId}/onboarding, /my/onboarding, comments | tasks pending / in_progress / done / not_applicable |
| Documents | /employees/{docId}/documents*, /my/documents*, review queue, expiring | pending / approved / rejected |
| Leave | /leave/policies, /leave/requests, /my/leave | casual / sick / earned / unpaid |
| Attendance and holidays | /attendance*, `/my/attendance/check-in | check-out, /holidays` |
| Payroll | /payroll/runs*, /payroll/adjustments, payslip PDFs | runs 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):
- 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. - Validates employment type, a probation of 0–24 months, the joining date, and the role (
ValidHireRole;super_adminis refused). - In one transaction:
- It calls
accounts.FindByEmailFold. If no account exists,Provisioncreates one (modecreated). If one exists, it is adopted (modeadopted), 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
NextEmployeeCodeandAssignRoleIfUnassigned. If the account already holds a different role, the result is a 409. - It writes
Employmentwith lifecycleonboarding, orprobationwhen probation months > 0. - It appends an
employment_createdlifecycle event. - It seeds
DefaultOnboardingTemplatetasks, due 7 days after joining.
- It calls
- After the transaction commits, it sends the invite email, best effort. The response always says
invite.sentand gives areason(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:readfor anyone else, plushr:document:restrictedfor Aadhaar front/back, PAN or cancelled cheque, - writes an
employee_document_access_logrow, 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 areapproved(seeresolveKYCGate). Resubmitting whilependingreturns 409.reviewActionreturns 409 if the track is notpending, so a reviewer cannot approve past a missing submission. - Who reviews what. Identity review is scoped by warehouse (
access.Scope). Bank approve/reject requiressuper_admin(reviewAction, lines ~773–784). For document content,cancelled_chequeis 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) intoaadhaar_number_ciphertext, and an HMAC-style blind index goes intoaadhaar_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.
NewModulerefuses equal keys.
- The DTO requires exactly 12 digits (
- Reviewers see the full number.
IdentityReviewdecrypts Aadhaar and PAN and returns them unmasked. Every such read writesdelivery_partner_kyc_identity_access_logwith 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 arekyc_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 insideCAREERS_TRUSTED_PROXY_CIDRSor the request carries the sharedTRUSTED_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.gologs a warning.
SubmitApplication step by step (application.go:283)
- 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. - Consent:
consent_acceptedmust be the literal string"true"."1","on"and"True"all fail.consent_notice_versionmust match^careers-notice-\d{4}-\d{2}$and be inknownCareersNoticeVersions(today onlycareers-notice-2026-09).talent_pool_consentnever blocks a submission. - Turnstile:
captcha.Verifyfails 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). - Resume required, at most 5 MB (
MaxResumeBytes). Storage must be configured. - 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.
- Looks up a published opening by slug.
sniffAndStitchPDFchecks the real bytes withhttp.DetectContentType, not the client's header, and only PDFs pass. The filename is sanitized and forced to end in.pdf.- Uploads to
careers/<openingDocID>/<applicationID>.pdf. The object key isjson:"-"and never appears in any DTO, CSV or log. - Inserts the row, with
ConsentAcceptedAt = AppliedAt = CreatedAt = nowtruncated to microseconds (the Postgres timestamp trap),lawful_basis=consentandingest_source=form. On failure it deletes the object. A unique violation means a duplicate application. - Best effort:
notifier.ApplicationReceivednotifies 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:
| Area | Routes (prefix /hiring/api/v1) | Service |
|---|---|---|
| Identity | GET /me | IdentityService |
| Openings and membership | /openings*, /openings/{docId}/members | OpeningService |
| Pipeline stages | /stages* | StageService |
| Candidates | /candidates, /board, /search, /{docId}, /stage, /hire, /resume, /activity | CandidateService |
| 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/verify | CalendarAdminService |
| 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}, /withdraw | CandidateTokenService |
| Erasure | /erasure/unlock, /candidates/{docId}/erasure-preview, /erase, /legal-hold | EraseService |
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,recruiterandhr_managersee every opening.hiring_managersees 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:
- No drag into a stage that maps to
hired(EntersHiredMirror→errStageHiredCannotBeEnteredByDrag,candidate.go:~408). Entering hired claims an employment relationship exists, and a drag cannot know that. - Hired is terminal (careers
UpdateStatus: oncehired, any other status is refused. Also,hiredneedshired_employee_idset 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 butHIRING_CALENDAR_SUBJECTis empty, boot also fails.ServiceAccountTokenSourcesigns a JWT and exchanges it at Google's token endpoint for the single scopecalendar.events, impersonating the subject mailbox. Tokens are cached, with a 2-minute skew.CalendarTokenServicewraps the token source and writes the one-rowhiring_calendar_healthtable (connected / expired / not_connected, last error).CalendarHealthRefresherticks 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.CalendarSyncServiceis a bounded worker pool: 4 workers (calendarSyncPoolSize), a queue of 64 (calendarSyncQueueCapacity).InterviewServicecallsAfterScheduled / 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 oncontext.Background(), never the request context, and recovers from panics.- The pool runs on
poolCtx, notreconcilerCtx.main.goexplains why: the pool has to keep draining queued jobs during the HTTP shutdown drain. Only itsDone()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
.icsdownload (GET /interviews/{docId}/ics). - Privacy gate inside sync:
syncCreatecallsnoticeOwed(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 acalendar_notice_withheldactivity is written to their timeline. The same gate inEmailServiceonly allows theingest_noticetemplate 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:
cmd/erase-careers-application: a manual operator tool. Without-commitit is a dry run. It prints a redacted DB target (host/db only) so you notice when your.envis pointing at prod.RetentionSweeper(retention_sweeper.go): runs every hour, 50 rows per tick, cutoffapplied_at < now − 12 months. It is safe with several ECS tasks: a race shows up as not-found and is countedalready_gone. It uses the fixed actor UUID…00c5. It is disabled by default in code and enabled indeploy/aws/variables.tf.POST /hiring/api/v1/candidates/{docId}/erase(hiring/services/erase.go:79): super_admin only (checked throughRoleResolver). It needsconfirm_document_idto equal the path id and a step-up unlock token fromPOST /erasure/unlock, which is checked againstCAREERS_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, ifstatus=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)))intocareers_erasure_suppressions, so a later CSV import won't recreate the person. The public form is not blocked by it. - After commit,
assertHiringTablesEmptycounts rows in 8 hiring tables and returns an error if any remain, in case a future migration forgotON DELETE CASCADE. - The audit log line carries
document_id,opening_idandactoronly.
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:
-copyand-delete-sourceare mutually exclusive, so you can't delete on the strength of a copy made in the same run.- Both default to a dry run.
-commitis 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.
| Variable | Read in | Effect when empty / false |
|---|---|---|
R2_KYC_BUCKET_NAME | main.go → kycR2 | Rider KYC uploads return 503. HR document upload/read fail. Boot warning |
KYC_ENCRYPTION_KEY | main.go (employee cipher), deliverykyc.NewModule | Boot fails (deliverykyc). Employee Aadhaar/bank numbers not sealed |
KYC_BLIND_INDEX_KEY | deliverykyc.NewModule | Boot fails. Must differ from the encryption key |
KYC_UPLOAD_RATE_LIMIT / _BURST | deliverykyc/module.go | Defaults to 0.2/s, burst 10, per actor |
R2_CAREERS_BUCKET_NAME | main.go → careers + hiring resume storage | Apply returns 503, resume download off |
TURNSTILE_SECRET_KEY | main.go → careers captcha | Captcha off (warning; preflight should flag it) |
CAREERS_TRUSTED_PROXY_CIDRS, TRUSTED_PROXY_SECRET | careers/module.go | Client-IP header never trusted, one shared rate-limit bucket |
CAREERS_RETENTION_SWEEP_ENABLED | careers/module.go | Sweeper not built. Only manual erasure |
CAREERS_ERASE_PASSPHRASE_HASH, CAREERS_ERASE_UNLOCK_MINUTES | hiring/services/erase_unlock.go | Erase route refuses ("not configured"), never bypasses |
HIRING_CALENDAR_SA_JSON, HIRING_CALENDAR_SUBJECT | main.go → hiring.NewModule | Calendar off, .ics only. Key set without subject = boot fails |
R2_RESTRICTED_DOCS_BUCKET_NAME | main.go, migrate-employee-kyc-storage | Assets "Employee KYC" downloads fail. The script refuses to run |
Traps
- 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 forMarkNoticeSent, so the naming format matters. - Hiring must never create employees.
OfferAcceptedonly notifies.POST /hr/api/v1/hiresis 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. - Nobody reaches
hiredby drag. Don't add a status-only path tohired. Careers refuses it whilehired_employee_idis null, andhiredis terminal. Always writejob_applicationsthroughCareersApplicationPort, never with raw SQL from hiring. Otherwise the board and careers disagree. - Aadhaar in rider KYC is shown in full to reviewers.
IdentityReviewreturns the decrypted Aadhaar and PAN. The only control after RBAC and warehouse scope isdelivery_partner_kyc_identity_access_log, and that write is best effort. Don't add Aadhaar to list/queue DTOs, logs or exports. KeepKYC_ENCRYPTION_KEY≠KYC_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). - Employee Aadhaar with no cipher is silently dropped.
Employee.AadhaarNumberisgorm:"-", andBeforeSavereturns early when no cipher is registered.main.gowarns "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 fromemployee.goandemployee_pii.go, not tested). Set a localKYC_ENCRYPTION_KEYwhenever you test employee details. - KYC buckets: private by design, but check the bucket, not just the code.
R2Client.Uploadreturns 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 ther2_access_key_idcomment indeploy/aws/variables.tf). That token also has an expiry date noted there. - The legacy public bucket.
R2_BUCKET_NAMEis world-readable. The oldemployees.aadhaar_front_urlandcancelled_cheque_urlcolumns can still be written throughapplyEmployeeDetails(human/services/employee.go:339). Never point one at a public object. Use the*_doc_idreferences. - DPDP retention gaps. Candidates have a 12-month sweep plus erasure. Riders and employees don't:
deletion_requested_atis 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. - Public routes need
endpoint_sync.go. The three careers public routes and the two hiring token routes must be inpublicPatterns, or prod returns 401 while tests pass. After an RBAC migration, every ECS task has to restart before grants take effect (migration 277 comment). - 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).
- Scope by membership, never by
actor.Role. A person with two roles breaks any hiring check that reads the token role. UseresolveScope. Note that HR'sHireEmployeedoes checkactor.Role. That is fine there only because super admin is the highest-priority role. - 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-applicationprints 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.
- Map the modules. In
cmd/server/main.go, list everyWarna local boot prints for People features, and what each one disables. Then start the server and check your list against the actual log. - 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/...(usego test -tags integration -run Hire ./internal/hr/services/). Readhire_integration_test.goand write down which test covers the "adopted" mode and which covers the 409 for a different role. - Pipeline trigger. On your local DB, insert a
job_openingsrow and ajob_applicationsrow by hand (minimal columns), then queryhiring_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. - Offer machine. Without running anything, use
OfferStatusTransitionsto list every path fromdraftto a terminal state. Then grep for any code that can produceexpired, and write one paragraph on what a correct expiry job would need (locking, activity rows, whether careers' "live offer" check changes). - Erasure guarantees. Run
go test -tags integration -run PurgeApplication ./internal/careers/services/against the scratch DB. Readapplication_hiring_erasure_integration_test.go. Then write a migration on a throwaway branch that adds a new hiring table withapplication_id … ON DELETE SET NULLand confirm which assertion would catch it. Do not commit. - 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.
- 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. - Calendar pool. Read
calendar_sync.goand 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
- Where is a delivery partner stored, and what two conditions make
KYCApprovedtrue? - Why does
HireEmployeehave a "created" and an "adopted" mode, and when is adoption refused? - Which two capabilities does
hr_managerdeliberately lack, and why? - How does an HR document get from R2 to a browser, and what must succeed before any bytes are sent?
- How does rider KYC detect a duplicate Aadhaar without storing it in plaintext, and what does a rejected record do to that check?
- What exactly must
consent_acceptedandconsent_notice_versioncontain for a careers application to be accepted? - What puts a new application on the hiring board, and what happens if no active stage maps to its status?
- Why can't a recruiter drag a candidate into "Hired", and what does the hire endpoint require instead?
- Name the three callers of
PurgeApplicationand the three conditions under which it refuses. - What happens to an interview when Google Calendar is down or the sync queue is full?
- Why is the imported candidate sometimes left off a calendar invite?
- What does
cmd/migrate-employee-kyc-storagemove, from where to where, and why are-copyand-delete-sourceseparate runs?
Answers
- In
employees, with adelivery_partnerrow inrole_assignments. There is no separate rider table.resolveKYCGaterequiresdelivery_partner_kyc.identity_status = 'approved'andbank_status = 'approved'(human/services/access.go:30). - 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.
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.- It is streamed through
GET /hr/api/v1/employees/{docId}/documents/{documentId}/content. No URL is ever issued. The service checks ownership orhr:document:read(plushr:document:restrictedfor restricted kinds), then writesemployee_document_access_log. If that write fails, the request fails. - 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 whereidentity_status = 'rejected', so a rejected record frees the number for a corrected submission. A clash returns 409. consent_acceptedmust be exactly the string"true".consent_notice_versionmust matchcareers-notice-YYYY-MMand be a key inknownCareersNoticeVersions(currently onlycareers-notice-2026-09).- Trigger
trg_hiring_pipeline_state_on_application_insert(migration 263) insertshiring_pipeline_statefor the lowest-sort, non-archived stage with a matchingmaps_to_status. If none matches, the trigger returns without inserting, so the application has no pipeline row. - Entering hired claims an employment relationship exists, so
MoveStagerefuses any stage that maps tohired.POST /hiring/api/v1/candidates/{docId}/hirerequires an existingemployee_id. In one transaction it setshired_employee_idandstatus=hiredthrough careers and moves the pipeline to the deterministic hired stage. It is granted to recruiter, and super admin gets in through the bypass. - Callers:
cmd/erase-careers-application,RetentionSweeper, andPOST /hiring/api/v1/candidates/{docId}/erase(super admin + passphrase unlock). It refuses onlegal_hold,status = hired, or a live offer (pending_approval/approved/sent/accepted). - 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
.icsdownload. noticeOwedis true for avoluntary_provision(imported) candidate with nonotice_sent_at. Until the DPDP notice email has gone out, the invite goes only to panelists, and acalendar_notice_withheldactivity is recorded.- It moves old
internal/assetsdocuments with category "Employee KYC" from the world-readableR2_BUCKET_NAMEbucket intoR2_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.