Engineering
How this would be built for real
Six decisions. Each one exists because of a specific, documented way the current system fails a citizen — not because it is good practice in the abstract. Together they are the argument that the portal's problems are structural rather than cosmetic, and that a redesign which does not change the data model underneath would regress within a year.
None of this is running. The prototype you clicked through has no server and makes no network calls. This page is a design, written to be argued with — see what's real for the line-by-line separation.
Decision 01
One canonical store of income facts, with provenance as a column
The failure it prevents. A citizen sees a prefilled figure they believe is wrong and has no way to tell whose mistake it is. So they either accept a number they don't trust, or they abandon the return. The correction channel exists on a different site, behind a different login.
Everything good about this prototype descends from a single structural choice, and it is not a UI choice. Instead of treating prefill as a convenience layer assembled at request time, treat every reported figure as a fact record that is immutable once written, and that carries with it the identity of whoever asserted it.
IncomeFact ├─ subject PAN of the person the fact is about ├─ kind salary | interest | dividend | capital_gain | tax_deducted ├─ amount in paise, integer — never a float ├─ period the assessment year it belongs to ├─ asserted_by the reporting entity's registered identifier (TAN, IFSC, SEBI reg) ├─ asserted_on the date THEY filed it, not the date we ingested it ├─ ingested_at ours, kept separately because the gap is diagnostic ├─ supersedes → prior fact id, if this is a correction └─ disputed_by → citizen assertion id, if the subject says it is wrong
Two consequences fall out immediately. The interface can always answer who told you this? — which is what makes a one-tap confirmation psychologically safe, because the citizen is checking someone else's declaration rather than swearing to their own. And a disagreement becomes a first-class record attached to the fact rather than a support ticket in a separate system: the dispute travels with the figure into assessment, appeal, and audit.
What it costs. Storage grows monotonically and nothing can be deleted, which makes retention policy a legal question rather than an engineering one. Reads need a "current view" projection, because the raw table is an append-only log and no screen should query it directly.
Decision 02
Prefill degrades to stale-with-a-date, never to a blank form
The failure it prevents. When an upstream reporting system is slow or down, the portal today serves an empty or partial return. The citizen cannot distinguish 'you earned nothing from interest' from 'we could not reach the bank', and files a return that is wrong through no fault of their own.
Prefill has many upstream dependencies and they do not fail together. The correct behaviour when one is unreachable is not to retry until the request times out, and certainly not to render zero. It is to serve the last known good projection and say how old it is, on the screen, next to the number.
request prefill(PAN, year)
│
├─ projection cache HIT, fresh ──────────► serve, no banner
│
├─ projection cache HIT, stale ──────────► serve + "as of 18 July"
│ + "one source is behind;
│ you can file, or wait"
│
└─ MISS and upstream unreachable ────────► do NOT render an empty form
name the missing source,
offer a notification when
it landsEach upstream sits behind its own circuit breaker, tripped per dependency rather than globally. A broker feed being down must not make salary figures unavailable, because the two have nothing to do with each other and most filers only need one of them.
What it costs. Someone can file on a stale figure that a later correction contradicts. That is a real risk and the reason decision 01 exists — the superseding record makes the discrepancy visible and attributable instead of looking like citizen error.
Decision 03
The refund is an explicit state machine, and the citizen sees the same one we do
The failure it prevents. 'Under processing' is one word covering nine distinct situations, several of which the citizen could clear in a minute if anyone told them what was wrong. The variance is what generates grievance volume — identical returns settling in a week or in three months, with no explanation for either.
There are nine states and the transitions between them are enumerable. The failure is not that the pipeline is complex; it is that the complexity is hidden and then apologised for.
not_filed → filed_unverified → verified → in_queue → under_review
│
┌─────────────────────┤
▼ ▼
HELD: evidence HELD: set-off
HELD: bank invalid HELD: mismatch
│ │
└─────────┬───────────┘
▼
determined → sent_to_bank
│
┌─────────┴────────┐
▼ ▼
credited failed
│
▼ new account, re-queueTwo rules make it useful rather than decorative. Every hold names the action that releases it — if we cannot state what the citizen should do, the hold is an internal problem and should not be surfaced as though it were theirs. Every wait carries a range, not an average: returns filed in the same week as yours are settling in ten to fourteen days. A stated range is honest about variance in a way a single number never is, and variance is the thing people actually find intolerable.
What it costs. Publishing the machine means committing to it. You can no longer quietly add a tenth state, and internal holds that were never meant to be citizen-visible have to be either named or removed.
Decision 04
Payments are a saga with an idempotency key, and there is a third outcome
The failure it prevents. A citizen pays, the bank confirms, the portal does not, and the money is gone with nothing to show for it. The forum remedy is to pay again — which is how people end up with two challans and a refund claim to recover the duplicate.
Any payment crossing a system boundary has three outcomes, not two: succeeded, failed, and we do not yet know. Most of the damage comes from software that has no vocabulary for the third and so reports it as the second.
idempotency_key = hash(PAN, assessment_year, amount_paise, intent_nonce)
initiated ──► pending_at_bank ──► confirmed ──► reconciled
│ │
│ └─► "Payment received,
│ receipt pending" ◄── the third state
│ the citizen sees this,
│ and is told not to pay again
▼
unknown ──► reconciliation job, every 15 min
never a user-facing "failed" until it settlesThe user-visible half matters as much as the mechanism. Payment received, receipt pending is a state the current portal cannot express, so it shows an error instead — and an error is an instruction to try again.
What it costs. Reconciliation is a permanent background job with real operational burden, and some payments genuinely sit in the unknown state for hours. The compensating honesty is that nobody is told to pay twice.
Decision 05
A verification session outlives the code it is waiting for
The failure it prevents. The single most-cited complaint about the portal: a one-time code arrives late, the session has already expired, and a form filled over forty minutes is gone. The citizen is punished for a delay in someone else's SMS gateway.
Draft state and authentication state have different lifetimes and should never share a timer. A draft is durable and belongs to the citizen; a session token is short-lived and belongs to the transport. Conflating them means an infrastructure hiccup destroys user work.
So: the draft is persisted on every meaningful change, keyed to the citizen rather than to the session. An outstanding verification code holds its challenge open, and the interface says so in as many words — take your time, nothing you have entered will be lost. Resend is offered after fifteen seconds instead of hidden, because a code that has not arrived in fifteen seconds usually is not coming.
What it costs. A longer-lived challenge is a slightly larger window for a replay attempt, which is why the challenge is single-use and bound to the draft it authorises rather than to the browser session.
Decision 06
Language is a build-time contract, not a runtime lookup
The failure it prevents. Partially translated interfaces are worse than untranslated ones. A citizen navigating in Tamil hits an English string at the exact moment of consequence — the confirmation, the deadline, the warning — because a fallback quietly filled the gap and no test failed.
The dictionary type is derived from the English source file, not hand-maintained alongside it. A missing Hindi or Tamil key is a compile error, so the build fails rather than the user. There is no runtime fallback chain, because a fallback chain is a mechanism for shipping exactly this bug quietly.
Interpolated strings are functions, not templates with placeholders. Hindi and Tamil place the verb and the postposition differently from English, and a {reporter} reported this on {date} template silently imposes English word order on both. Number and date formatting go through the platform's own locale support, so lakh and crore grouping is correct by construction rather than by a regular expression someone wrote once.
What it costs. Adding a citizen-facing string means touching three files and cannot be deferred. That friction is deliberate — it is the mechanism, not a side effect of it.
What is deliberately absent
No message queue between the citizen and their own figures. No microservice boundary that turns one screen into six network calls. No machine learning anywhere near an assessment decision — a figure that determines what someone owes must be attributable to a named reporter, and a model output is not. The hard part of this system is record-keeping and honesty about failure, and neither is solved by adding infrastructure.
Every decision above has a cost stated next to it. A design document without those is a sales pitch.