KPI Media · Internal · Engineering decisions

What we chose, and what we turned down

Sixteen engineering choices. Each one carries what was rejected and why, and what the choice costs — because a decision with no stated cost has not been made, only announced. Three of them were reversed after being taken; two are only as strong as a lint rule or an alarm; three more were made and never written down at all.

16 choices 3 later reversed 3 unrecorded 1 contradicts an ADR

How to read this with an engineer

Most of these are settled and should stay settled — they were argued once and the reasoning is in the ADRs. Four are worth genuinely reopening in a technical conversation, and they are marked open to argument. Two are marked accepted risk: they were decided knowing they leave a real hole, and the hole is described rather than hidden.

The last group — decided but never written down — is where an experienced engineer will push first, because there is no recorded reasoning to push against.

Group AFoundations

Five choices that everything else assumes. Changing any of them is not a refactor.

One product, not three apps

ADR-0001 · settled

HRMS, Org Chart and Leave are modules inside a single deployment backed by a single Postgres database, with more Base44 apps expected to follow.

Rejected
Separate apps or services. The modules are not independent — Leave approval needs the reporting line Org Chart owns, and all three describe the same people. Splitting means either duplicating the employee record three ways or building service-to-service infrastructure that buys nothing at 32 employees. There is no scaling pressure here; the pressure is correctness and a two-person team.
Costs
All modules deploy together — no independent release cadence, and a fault in one can affect the whole app.
Module boundaries are maintained by folder structure, table ownership and lint rules rather than by the network. Discipline, not physics.

Self-hosted Supabase on Zeabur, Singapore

ADR-0002 · settled

Supabase supplies four things this platform would otherwise write itself: Postgres row-level security, GoTrue for Workspace OAuth, Storage for receipts and identity documents, and a scheduler. Self-hosted rather than Cloud so the data sits in infrastructure the company controls, in a region we can point at under PDPA.

Rejected
Cloudflare end to end — Workers plus D1. D1 is SQLite and has no row-level security, which pushes authorization back into application code. That is the specific failure this rebuild exists to fix.
Supabase Cloud, ap-southeast-1. Would have given daily backups and point-in-time recovery for free. Rejected on data control.
Workers plus Postgres on Zeabur — reconsidered in August, and genuinely different because Postgres keeps RLS. A Worker reaching Zeabur Postgres needs Hyperdrive, which needs the database publicly reachable: worse exposure for a database holding bank accounts and national ids than the problem it solves. It also means hand-writing the read path PostgREST supplies and the OAuth GoTrue supplies — security-critical code, on a platform built by a junior engineer.
Costs
We own backups. Self-hosted Supabase has no managed backup and no PITR. Automated pg_dump to off-host storage plus a rehearsed restore is required work, not a nice-to-have.
Roughly ten containers to patch and keep in version lockstep across three environments.
Supabase Edge Functions run on Deno and cannot render payslip PDFs, so a separate Node service is mandatory regardless of anything else.

Row-level security is the authorization floor

ADR-0003 · written, never executed

RLS on every table with no exceptions, as the last line of defence rather than the only one. The browser reads and performs simple writes straight through PostgREST; writes carrying an invariant go through our own Node service, which connects as the calling user so RLS still applies inside our own API.

Rejected
Authorization in application code. The Base44 prototype could only enforce a binary admin/user flag at the data layer, so finance_hr and super_admin were indistinguishable in the database and only UI conditionals separated them. That is the whole reason for this rebuild.
Costs
Two request paths exist, and the team must hold one rule: service_role never appears in code that serves a user request.
Policies are security-critical code written in a language the engineer is least experienced in.
Role restrictions must be verified by calling the API directly. Clicking through the UI proves nothing.

The test matrix is written before the policies

ADR-0003, amended · settled

Role × relationship-to-row × table × operation → allow or deny, derived from CONTEXT.md and the ADRs before any policy exists, and signed by the senior. The original wording forbade AI-drafted policies; that is relaxed deliberately, because review was never the real control.

Rejected
Code review as the control. An RLS bug is an omission — a policy that wrongly permits something looks entirely normal in a diff. Reading SQL for absences does not work, however careful the reader. So the control moves to tests, with one ordering rule: drafting either artefact with AI is permitted, deriving the tests from the policies is not, because they would then prove only that the policies do what they do.
Costs
A PR that changes a policy and its test together fails review. It means the expectation was adjusted to fit the implementation.
Matrices for hrms, org, leave and Storage do not exist yet, which means no table in those schemas may be added. That is a real gate on the payroll phase, and payslip access is object-level rather than row-level so it will not simply copy the core shape.

A module owns a Postgres schema and a code folder

ADR-0005 · settled

Ownership is a GRANT, not a prefix people remember. core, hrms, org, leave — plus ops, which is plumbing rather than a module. A module may read core and may not touch another module’s tables at all. In code, a module declares its own routes, nav entries, permissions and migrations, and the shell mounts it from a registry.

Rejected
Naming conventions. New modules will be added over time by people who did not build the original, so “module” needs a definition with teeth.
Costs
Anything genuinely shared has to be promoted into core deliberately — which is the point, but it is friction every time.
Lint rules can be suppressed by someone in a hurry. The boundary is only as strong as code review keeps it.

Group BIdentity, and the master key

Who gets in, how they get linked to an Employee, and where the credential that bypasses every policy is allowed to live.

Google Workspace SSO only, no passwords

ADR-0004 · settled

Google OAuth restricted to the Workspace domain, and nothing else. 2FA is inherited from Workspace, which closes the prototype’s gap of having no second factor on roles that can read every salary and bank account. Offboarding becomes one action in Google rather than two systems that drift.

Rejected
Email and password with OTP and reset — which the prototype shipped. Dropping it removes storage, reset flow, OTP mail, strength metering and four pages, all at once.
A break-glass local super-admin credential — proposed, then withdrawn in August 2026. Enabling email/password in GoTrue re-exposes the recovery, OTP and signup endpoints for everyone, and the account would hold super_admin behind a single password with none of the Workspace 2FA the rest of the platform inherits. That is the most valuable credential in the company, protected worse than any other.
Costs
A Google outage means nobody signs in, and that is accepted — payroll is monthly, Workspace outages are measured in hours, and a run that starts on the 20th has slack. Genuine emergency access is psql, which gets exercised at every release rather than being a password nobody has tried.
Anyone without a Workspace account cannot be given access. Shehan, the one contractor, holds one, so this constrains nobody today.
A trap: enable_signup must stay true in GoTrue, or OAuth first sign-ins are blocked and nobody can ever log in. “No self-registration” is carried by the Internal consent screen and by the account trigger — not by that setting.

The Employee record is the invitation

ADR-0004, amended · migration 0003

A trigger on auth.users insert looks for an active Employee holding that work_email. Finding one, it creates the Account and grants employee — every other role still granted deliberately by a super admin. Finding none, it creates nothing and the person lands with no access whatsoever.

Rejected
“HR provisions an Account against an Employee that already exists” — the original wording, which cannot be implemented literally. core.account.id is a foreign key to auth.users(id), and that uuid does not exist until the person completes a Google sign-in. There is nothing to provision against beforehand.
Costs
Creating the Employee record is now the act that grants access, rather than a second step afterwards. Since HR creates the Employee anyway, the removed step was one nobody could skip and everybody could forget — but it also means there is no longer a moment where someone decides “this person should have access.”
A leaver whose Workspace account is deleted and later recreated will not silently relink; the stale Account must be removed by hand. Deliberate — silent relinking would let a new Google identity inherit an existing Employee.

One Node service, service_role confined by a build rule

ADR-0037 · accepted risk

Two ADRs ask for Node for opposite reasons. Background work — payroll batches, PDF rendering, email, backups — has no user attached and must run as service_role, which bypasses RLS entirely. Invariant-carrying writes must run as the calling user so RLS still applies. One of those must hold the master key; the other must never hold it.

We run one deployment. service_role is constructed in exactly one file, src/jobs/service-client.ts, and an ESLint boundary rule fails the build if anything under src/api/ imports it, directly or transitively.

Rejected
Two deployments from one repository. Strictly safer — the master key is simply absent from the environment of the process that answers user requests, so the shortcut is unavailable rather than discouraged. Rejected on operating cost: one part-time operator, and a second service is a second thing to patch, deploy and debug at 2am.
Workers for the jobs half. Nightly pg_dump to R2 cannot run in a Worker at all — it is a binary over a TCP connection and Workers have neither. That alone makes a container mandatory.
Workers for the API half only. Buys the one real thing: the master key physically absent from the process serving requests. Rejected because the container is mandatory regardless, so moving the API out removes nothing and adds a third deploy target, a second secrets store, a fourth local dev process, and forces every multi-step invariant into plpgsql.
Costs
The key does sit in the environment of a process that serves user requests. The boundary is a build rule, not a deployment boundary, and it is only as strong as the rule staying enabled. Suppressing it is a review failure, not a style disagreement.
Reversal is cheap and pre-planned. If the team grows past two engineers, split into api and jobs deployments — the folders already divide along the seam and no code has to move.

Group CEnvironments and delivery

Three choices shaped almost entirely by one fact: the platform is built by a junior engineer who cannot hold production credentials, and operated by one senior who is not full-time on it.

Three environments, one Zeabur project, no dashboard for anyone

ADR-0038 + amendment · accepted risk

Local, dev and production, never connected — no sync, no replication. What travels between them is code and migration files, through git: the same files applied twice, at two different times. Both Supabase stacks live in one Zeabur project, and nobody but the senior ever gets the dashboard. The junior receives a DEV_SUPABASE_URL and a DEV_SUPABASE_ANON_KEY, by hand, and nothing else.

Rejected
Dev on Supabase Cloud’s free tier. Free, isolated, nothing extra to patch. Rejected on parity: Cloud runs newer versions than a pinned self-hosted stack, so nobody would exercise the container wiring until production — and there is no staging tier to catch it.
Two separate Zeabur projects — originally chosen, then reversed. The decisive argument was that Zeabur access is per project, so granting the junior a dev dashboard would hand over production’s service_role key and database password. That argument assumes the junior is granted a dashboard. They are not, which dissolves it rather than answering it — and retires the open question of whether Zeabur’s collaborator permissions are per-project, since nobody is a collaborator.
A fourth staging tier. More environments than people.
Costs
Dev and production share a disk and a CPU quota. A runaway dev migration or dev log volume that fills the disk stops production Postgres accepting writes — a payroll outage caused by the junior’s work. This is the one failure the topology genuinely exposes.
Two controls, and neither is strong alone: a disk alarm at 70% — unverified that Zeabur offers one — and hostnames chosen to be deliberately unalike, so that a --db-url pasted at 11pm looks wrong rather than differing by four characters.
Reversal: the moment a second person needs to log in to Zeabur, the objection returns intact and this splits into two projects.

Production moves only by hand, and every release rehearses a restore

ADR-0038, ADR-0008 · settled

main is protected — PR, one approving review from the senior, CI green, no force-push; merging deploys dev. release is protected so only the senior can push it, and pushing is what deploys production. Migrations are applied to production by hand, after a rehearsal on a freshly restored production dump and before the promotion.

Rejected
A staging stack. Roughly ten more containers to patch, whose data drifts out of usefulness quickly. The safety net staging usually provides already exists inside the product: payroll moves draft → submitted → finalised, so a full batch can be reviewed line by line with nothing committed until a super admin approves.
Automatic deploy on merge to release. Migrations must precede promotion — new code against an old schema breaks; old code against a new schema usually survives. Automating the promotion would remove the human who guarantees that ordering.
Costs
Releases take fifteen minutes of one specific person’s attention, and cannot happen while that person is away.
The rehearsal is the backup test. Tying a restore to every release is how you find out the backups work — weekly, on a Tuesday morning, rather than during an incident. Skipping it because a migration “looks safe” quietly removes the only proof the dumps are restorable.
There is no shared environment where Finance can try something before it is live. Validation happens through the draft payroll workflow instead.

The shell is built first; HRMS is ported into it

ADR-0036 · open to argument

hr/ is the prototype: a flat React/Vite SPA of about 123 .jsx files still on @base44/sdk. The target shape is a shell mounting self-declaring module folders from a registry. Those are two different shapes, and the port has to start from one. We build the shell — registry, navigation, layout, auth — then move the prototype’s pages in as modules/hrms/, swapping the SDK for the vendored shim as each screen moves.

Rejected
Port in place, restructure later. Fastest route to a working payroll system and the clearest possible diff. Rejected because “later” lands after payroll is live and real, and restructuring the frontend of a running payroll system is exactly the class of risk being avoided.
Rewrite the frontend. Discards working screens and the shim.
Costs
Nothing user-visible ships until the shell exists. A long stretch with nothing to show most of the company — which is also the stretch a new engineer most needs a visible win.
Every .jsx file is touched during the move, so the TypeScript conversion happens in the same pass rather than as a second sweep. Larger diffs, harder reviews.

Group DCorrectness and observability

Two choices that both cost real time up front, and both exist because the reviewer is not full-time and the builder is one year in.

Payroll correctness is fixed as worked examples, before the code

ADR-0039 · not started

Twenty to thirty worked examples — inputs and the exact expected output — authored by the reviewer together with Finance before payroll code is written. The implementation’s stated job is to make them pass. Each statutory scheme crossed with each edge case: a mid-month joiner, a leaver with more Annual Leave taken than earned, a non-NPWP Indonesian carrying the 1.2× surcharge, a December run with encashment, a month containing a Directors’ Fee.

Rejected
The engineer writes tests alongside the code. Standard practice and far less work up front. Rejected as circular: an engineer who misreads the incomplete-month rule writes a test asserting the misreading, and it passes forever. Their tests prove the code does what they believe, not what MOM requires.
Rely on the shadow payroll month. Kept as an acceptance gate. Rejected as the development test: it arrives at the very end, exercises everything at once, and reports that one employee’s number is wrong without saying which of six rules produced it.
Costs
Payroll implementation is blocked until the table exists. Deliberate — it makes the table the first ticket of the payroll phase rather than a documentation task. It also needs Finance’s calendar, so it is the longest-lead item on the project and has not been started.
In exchange: Finance can validate the table without reading any code, and review shifts from auditing logic to checking a number — which is what makes reviewing a junior’s payroll work sustainable at all.

Errors are reported by our own notifier, not a vendor

ADR-0040 · settled

Four alarms — application errors, uptime, backup-did-not-land, scheduled-job-failed — all through one notify() function to one Slack channel. Every error also lands in ops.error_log, the queryable history that replaces a vendor dashboard. Payloads are built from a fixed field list: fingerprint, count, module, route, employee_code. Nothing else.

Rejected
Sentry free, errors by email; a vendor whose free tier includes Slack (GlitchTip, Better Stack, Rollbar); a Sentry-alert-to-webhook relay. The deciding argument is not cost. Error-tracking SDKs capture local variables in stack frames. On this platform a frame can hold a salary, a bank account or a national id, which would be transmitted to a third party and relayed into a channel the whole team reads. Suppressing that means scrubbing rules that must stay correct against every stack trace anyone writes in future. Building the payload ourselves makes the guarantee structural — there is nothing to scrub.
Costs
No source maps for minified frontend traces, no release tracking, no breadcrumbs. Acceptable at 32 users with a dev environment that reproduces faults.
Error storms must be handled or the alerting destroys itself. Slack receives a given fingerprint at most once every fifteen minutes, carrying the count suppressed since the last post. Without that, one throwing loop rate-limits us out of our own channel.
Alarms 3 and 4 matter most and are the easiest to get wrong, because both failures look exactly like nothing happening.

Group EThe edges

Three smaller choices that each surprise someone reading the setup for the first time.

No WAF, and DNS stays at GoDaddy

ADR-0002, amended · settled

Cloudflare’s role narrows to Pages (SPA hosting, per-PR preview URLs, CDN and TLS) and R2 (backups and the Storage backend). Production hostnames are plain unproxied CNAMEs from GoDaddy.

Rejected
Moving nameservers to Cloudflare. kpimedia.co carries the Webflow marketing site and Google Workspace MX records; recreating those risks the company website and company email for a gain this platform does not need.
A WAF, which was in the original decision and is dropped. A WAF defends an application whose authorization lives in application code, by signature. This one’s lives in row-level security behind Google SSO, for 32 named people — managed rulesets would generate noise rather than protection.
Costs
No edge rate limiting. If the auth path ever needs it, it belongs at Kong.
DNS changes happen in a system nobody has listed an owner for — one of the seven facts still to verify.

Supabase Storage runs on a Cloudflare R2 backend

ADR-0002, amended · settled

Claim receipts and employee documents go to Storage pointed at R2, not a Zeabur volume. Bucket RLS policies do the authorization — the same model as the tables, so a receipt showing a medical condition is protected by a policy rather than by a code path someone remembered to write.

Rejected
A Zeabur volume. The bytes would then sit on the same disk production Postgres runs on, and inside the operator’s backup obligation as a second thing to cover.
Costs
A second set of credentials per environment, and the exact environment variable names depend on the pinned image version — check them against the image, not against the docs.

The seed carries real names and real work emails

ADR-0015, ADR-0025 · open to argument

seed.sql is committed to git with all 32 real names, employee codes, job titles, the reporting line and real @kpimedia.sg addresses, plus three synthetic leavers on a reserved .invalid domain. Compensation is entirely invented, and the seed refuses to run if any compensation row is not synthetic — because db reset runs the seed automatically and both stacks share a project.

Rejected
A fully synthetic seed. Sign-in is Workspace SSO and the account trigger matches on work_email, so invented addresses mean nobody can authenticate into dev or local at all — including the reviewer, who would sign in successfully and land nowhere.
Costs
Real names live in git history permanently. Judged acceptable because ADR-0025 keeps exactly that set forever anyway — code, name, dates, titles, career history, reporting line — and destroys the rest five years after a last day. The destroy list is the compensation table, which is invented.
A dev payslip job reads live inboxes. So the mail transport rewrites every recipient outside production and throws if unconfigured. Rewriting rather than filtering, because a filter can let a row through and a rewrite cannot; failing closed, because the alternative is a missing environment variable mailing thirty-two colleagues a test payslip.

Group FDecided, but never written down

Three choices that live only in technical-design.md, with no decision record and no rejected alternatives. They are the ones an engineer will push on first, because there is no recorded reasoning to push against — and one of them quietly contradicts an ADR.

  • Hono, over Express, Fastify or Nest

    The stated reason is the smallest concept count — routing, middleware, context — and the best TypeScript inference of the Node options, which matters on a payroll system built by one junior. That is a good reason and it is written in technical-design.md §4, but it was never argued against anything. An engineer who knows Express will ask, and the honest answer today is that the alternatives were not written up.

    Recorded in technical-design §4 · no ADR
  • node-cron, not pg_cron — and this contradicts ADR-0002

    ADR-0002 lists pg_cron among the four things Supabase supplies that made self-hosting worth it. technical-design.md §4 specifies node-cron in the Node service instead. Nothing records the change or why it was made. It matters more than it looks: pg_cron survives the Node service being down and node-cron does not, and three of the four alarms exist precisely because a job silently not running looks exactly like nothing happening.

    Conflict between ADR-0002 and technical-design §4 · needs a decision either way
  • Branded money types

    SGD and Local as distinct types that cannot be assigned to one another, enforced by zod at the API boundary. It rests on ADR-0012 and ADR-0013, but as an engineering choice it was never taken — it constrains every function signature in payroll, it does not exist in plpgsql, and that constraint is itself part of why money computation stays in TypeScript. Worth making explicit before it is load-bearing rather than after.

    Recorded in technical-design §8 · no ADR

What would reopen the settled ones

A third engineer reopens two at once: service_role splits into two deployments (ADR-0037), and the single Zeabur project splits into two (ADR-0038). Both reversals are pre-planned and neither requires code to move.

Zeabur not offering a disk alarm reopens the shared-project decision immediately, because the mitigation would then be imaginary.

The Node image lacking pg_dump reopens ADR-0037’s central rejection — that was the argument that made a container mandatory.