<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Ravi Pandey</title>
        <link>https://ravipandey.com</link>
        <description>Long-form notes on scalable architecture, multi-tenant SaaS, and leading engineering teams.</description>
        <lastBuildDate>Sun, 20 Sep 2026 04:24:59 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Ravi Pandey</title>
            <url>https://ravipandey.com/favicon.ico</url>
            <link>https://ravipandey.com</link>
        </image>
        <copyright>All rights reserved 2026</copyright>
        <item>
            <title><![CDATA[Three sources, none of them wrong]]></title>
            <link>https://ravipandey.com/articles/apis-you-dont-control</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/apis-you-dont-control</guid>
            <pubDate>Sat, 20 Sep 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>The integration specification I spent the most time with in 2025 was a PDF, last revised in 2016, describing a fixed-width file format delivered over SFTP. It had a field marked "reserved for future use" that turned out to contain something important, and a sample file whose column offsets did not match the table on page nine.</p>
<p>This is normal. If you're coming from consumer web work where every integration is a JSON API with a decent SDK and a status page, financial infrastructure is a genuine adjustment. The systems are old, they're load-bearing for the entire industry, and their conservatism is mostly justified. You will not be changing them. The engineering problem is entirely on your side of the line.</p>
<h2 id="land-raw-transform-separately">Land raw, transform separately</h2>
<p>The one architectural rule I'd keep if I could keep only one: nothing from an external system writes directly into a domain table.</p>
<p>Every payload lands raw first. The exact bytes of the file or the response body, a hash, the source, when it arrived, and a status. Parsing and transformation happen afterwards as a separate step reading from that store. It costs a table and some disk and it has paid for itself repeatedly.</p>
<p>It pays off when a provider changes a format without telling you, because you can reparse history instead of asking them to resend six months of files. It pays off when your own transformation has a bug, for the same reason. And it pays off in an audit, when somebody asks what a registrar actually told you on a date eighteen months ago and the honest answer needs to be the payload rather than your interpretation of it.</p>
<p>We keep raw payloads indefinitely. Storage is the cheapest thing in the system and it's the only copy of the truth that isn't downstream of our own code.</p>
<h2 id="webhooks-are-a-hint-not-a-delivery-guarantee">Webhooks are a hint, not a delivery guarantee</h2>
<p>Most providers that offer webhooks describe them as notifications, and the good ones mean it literally.</p>
<p>Deliveries arrive out of order, arrive twice, and sometimes don't arrive. So a webhook handler does two things and no more: verify the signature, and enqueue a job to go and fetch the current state of whatever the event referred to. It never trusts the body of the notification as data. If two notifications for the same entity arrive out of order, both jobs fetch current state and both reach the same answer, which makes ordering irrelevant instead of a problem to solve.</p>
<p>The backstop is a poll. Whatever the webhooks cover, there's also a scheduled reconciliation sweep that fetches everything changed since the last successful sweep and picks up whatever the notifications missed. Every provider I've integrated with has dropped events. None of them advertise it, and you don't find out from an error, you find out from a customer.</p>
<h2 id="retries-when-the-operation-moves-money">Retries, when the operation moves money</h2>
<p>Standard retry advice assumes idempotent reads. Outbound writes into financial systems are a different problem, because the failure mode isn't a slow response, it's a request that succeeded on their side and timed out on yours.</p>
<p>Retrying blindly submits the order twice. Not retrying leaves you not knowing whether it went through at all, which is worse.</p>
<p>So every outbound write carries a client-generated reference, and the retry path is query-then-write rather than write-again: look up the reference on their side, and only submit if it isn't there. Providers vary in how well they support this. Where a provider has no lookup by client reference, the operation stops being automatic. It goes into a queue for a human, with the timestamp and the payload, because a person checking a portal is slow and correct, and a retry loop is fast and occasionally catastrophic.</p>
<h2 id="completeness-is-not-a-field">Completeness is not a field</h2>
<p>A subtle one that caused us real trouble: a feed that is incomplete at four in the afternoon looks exactly like a feed that is complete at six.</p>
<p>You get a file, it parses, every row is valid, and nothing about it announces that another forty per cent of the day's records are still coming. If your valuation job runs on a schedule and the data happens to be partial, you produce confidently wrong numbers.</p>
<p>Where a provider gives you a control record with an expected count, use it and reject the file if it doesn't match. Where they don't, you infer completeness, and you say so: data is marked provisional until whatever heuristic you're using is satisfied, and anything computed from provisional data is flagged as such in the interface. Advisors are entirely comfortable with "provisional, awaiting full feed". They are not comfortable with a number that changes overnight with no explanation.</p>
<h2 id="when-two-authoritative-systems-disagree">When two authoritative systems disagree</h2>
<p>Here's the part that took a real decision rather than an engineering answer.</p>
<p>For a given holding we may have three views: what our own ledger computes from transactions we've processed, what the registrar's statement says, and what the custodian reports. They disagree regularly, and none of them is wrong exactly. They're describing the same position at different points in a settlement cycle, with different cut-off times.</p>
<p>The trap is trying to resolve this in code with a cleverness heuristic. What it actually needs is a written precedence policy, decided by people who understand the domain rather than by whoever is writing the merge function.</p>
<p>Ours says the registrar is authoritative for unit counts, the valuation agency is authoritative for prices, and our own ledger is authoritative for nothing at all. Our ledger's job is to predict what the registrar will say and to raise a break when it doesn't. That framing changed how the team thought about the system: we're not the source of truth, we're a fast, provisional model of it, and every discrepancy is either a defect in our model or a genuine timing difference, and it has to be classified as one or the other.</p>
<p>Which is really the whole thing. You cannot make an external system reliable, and you shouldn't try. You can be explicit about what you know, when you knew it, who told you, and how confident you are. Everything else in the integration layer is bookkeeping around that.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[It is never just the logo]]></title>
            <link>https://ravipandey.com/articles/branding-without-a-build-step</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/branding-without-a-build-step</guid>
            <pubDate>Sat, 12 Jul 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Every multi-tenant product I've worked on has the same conversation about six weeks in. A client wants their logo in the header. Fine, says everyone, that's a config field.</p>
<p>It is never just the logo. It's the logo, then the primary colour, then the primary colour in the email templates, then their font, then their domain, then a request to move the logo because at their aspect ratio it looks squashed. Each individual ask is twenty minutes. Together they're an architecture, and if you don't decide that up front you get a <code>theme.ts</code> with a switch statement in it that somebody will still be maintaining in 2029.</p>
<p>The constraint I set on the coworking platform was that onboarding an operator could not involve a deploy. Not "a fast deploy". None. If the theming story requires a build, it isn't a config field, it's a fork with extra steps.</p>
<h2 id="variables-rendered-per-request">Variables, rendered per request</h2>
<p>The whole thing rests on CSS custom properties, which is not clever, but the placement is what matters.</p>
<p>Tailwind's tokens map to variables rather than fixed values, so the utility classes stay the same across every tenant and only the variable definitions change. Then the tenant's values get inlined into the document at request time in the root layout:</p>
<div class="group/code relative"><pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> theme <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token function">getTenantTheme</span><span class="token punctuation">(</span>tenantId<span class="token punctuation">)</span>

<span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>html</span> <span class="token attr-name">lang</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>theme<span class="token punctuation">.</span><span class="token property-access">locale</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>head</span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      &lt;style
        dangerouslySetInnerHTML=</span><span class="token punctuation">{</span><span class="token punctuation">{</span>
          __html<span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">:root{</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token function">themeToCssVars</span><span class="token punctuation">(</span>theme<span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">}</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
        <span class="token punctuation">}</span><span class="token punctuation">}</span><span class="token plain-text">
      /&gt;
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>head</span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>body</span><span class="token punctuation">&gt;</span></span><span class="token punctuation">{</span>children<span class="token punctuation">}</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>body</span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
  </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>html</span><span class="token punctuation">&gt;</span></span>
<span class="token punctuation">)</span>
</code></pre><button type="button" aria-label="Copy code" class="absolute top-3 right-3 flex items-center gap-1 rounded-md bg-zinc-800/80 px-2 py-1 text-xs font-medium text-zinc-300 opacity-0 transition group-hover/code:opacity-100 hover:bg-zinc-700 hover:text-zinc-100 focus-visible:opacity-100 max-sm:opacity-100 dark:bg-zinc-700/60 dark:hover:bg-zinc-700"><svg viewBox="0 0 16 16" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="h-3.5 w-3.5 stroke-current"><path d="M5.75 4.75h5.5v6.5h-5.5v-6.5Z"></path><path d="M10.25 4.75V3.25h-6.5v6.5h1.5"></path></svg>Copy</button></div>
<p>Inline in the head, not a stylesheet link, and not applied by client-side JavaScript. That's the part people get wrong, including me, on the first attempt.</p>
<p>If the theme is applied after hydration, every visitor sees a flash of your default brand before the correct one arrives. On a fast connection it's a blink. On a mid-range Android phone on a hotel network it's most of a second, and the one person guaranteed to notice is the operator you just onboarded, on the demo call, in front of their own team. It's a rendering bug that only ever gets found by the people you least want finding it.</p>
<p>Fetching the theme puts a database read on the critical path for every request, so it's cached aggressively with explicit invalidation when the tenant record changes. That cache and the host-to-tenant lookup are the two pieces of the system I've spent the most time thinking about since, which I've written about separately in the piece on where tenancy lives.</p>
<h2 id="brand-colours-are-not-design-tokens">Brand colours are not design tokens</h2>
<p>Here's the one I didn't see coming.</p>
<p>An operator sends you their brand colour. It's a mid-yellow, because their brand book says so, and your primary button renders white text on it. The result is unreadable, fails WCAG comfortably, and it isn't the client's fault or your designer's fault. It's that a brand palette and a UI palette are different things solving different problems, and treating the first as the second breaks the moment someone's brand is light.</p>
<p>So the tenant record stores the brand colour, but the tokens the UI actually uses are derived from it at save time. We compute the foreground for each surface by contrast ratio rather than assuming white, generate the hover and active states by adjusting lightness in a perceptual space instead of plain HSL, and run a contrast check as part of saving the theme. If a combination fails, the admin sees it before it ships, with the nearest passing alternative offered.</p>
<p>Two colours out of the first dozen operators failed that check. Both times, showing them the adjusted version alongside their original ended the discussion in a minute, because the adjusted one obviously looked better.</p>
<h2 id="fonts-assets-and-email">Fonts, assets, and email</h2>
<p>Fonts are where the no-build rule bites hardest. <code>next/font</code> is excellent and it is build-time by design, which means a tenant-chosen font can't use it. We settled on a curated set of families bundled at build with a variable per tenant selecting between them, and self-hosted webfont files for the two enterprise clients who genuinely needed their licensed typeface. It's a compromise. An operator can't upload a font file and see it live, and so far nobody has asked to.</p>
<p>Logos taught us to constrain by box rather than by dimensions. The first version specified a height and let width flow, which is fine until you get a wordmark with an eleven-to-one aspect ratio next to a circular emblem. Now every logo renders inside a fixed box with object-fit containment, uploads are checked for transparency and minimum resolution, and there are separate slots for the horizontal wordmark and the square mark because those are genuinely different assets and asking for one file to serve both never works.</p>
<p>Email is a separate rendering pipeline with separate rules and it will not use your CSS variables. Mail clients want inlined styles, several of them apply their own dark mode inversion whether you like it or not, and a logo with a transparent background can end up invisible. Same theme record, different renderer, and it needs testing on its own.</p>
<h2 id="the-thing-i-still-havent-solved-nicely">The thing I still haven't solved nicely</h2>
<p>Favicons and the PWA manifest.</p>
<p>Both are static file references that browsers cache with unusual enthusiasm, both need to vary per tenant, and the manifest needs icons at half a dozen sizes generated from whatever the operator uploaded. We generate them on upload and serve them from tenant-scoped routes, which works, but the caching behaviour differs enough between browsers that I don't fully trust it. Every so often somebody reports the wrong icon in a bookmark and I have no reliable way to reproduce it.</p>
<p>If you've solved that one properly I'd genuinely like to hear about it.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[The morning a portfolio doubled]]></title>
            <link>https://ravipandey.com/articles/designing-for-corporate-actions</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/designing-for-corporate-actions</guid>
            <pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Take a scheme that announces a 1:1 bonus. Every holder gets one extra unit for each unit held, and the NAV per unit halves. A client with 500 units at ₹80 has 1,000 units at ₹40. Their portfolio is worth exactly what it was worth yesterday, which is the entire point of a bonus issue.</p>
<p>Now consider a system that stores units in one table and gets NAV from a feed. The bonus units are applied by a job on the morning of the ex-date. The revised NAV arrives from the AMC that evening, because that's when NAVs arrive.</p>
<p>For most of a working day, that client's portfolio shows double its actual value. Advisors saw it. One of them called.</p>
<p>Nothing was broken in the sense of a stack trace, and every individual component did what it was written to do. The mistake was earlier and more conceptual: treating a corporate action as an update to a number rather than as an event with its own set of dates.</p>
<h2 id="three-dates-and-they-are-all-different">Three dates, and they are all different</h2>
<p>A corporate action has an announcement date, an ex-date, a record date and a payment date. They can be days apart, and which one governs depends on the action.</p>
<p>Units change on the ex-date. Entitlement is determined by who holds on the record date. Cash moves on the payment date. If your model has a single <code>effective_date</code> column you have already lost, because the client who sells between the record date and the payment date is still entitled to the dividend, and your system now needs to pay someone who no longer holds the position.</p>
<p>That case is not rare, and it's the one that exposes whether a design is right. Entitlement is computed from the holding as of the record date, stored as a receivable, and settled on the payment date independently of what the client does with the units in between.</p>
<h2 id="what-each-type-actually-does">What each type actually does</h2>
<p><strong>Splits and consolidations</strong> change the unit count and the NAV in inverse proportion. Value is unchanged. Cost basis per unit changes but total cost does not, so the existing cost has to be redistributed across the new unit count rather than recalculated.</p>
<p><strong>Bonus issues</strong> look like splits from a distance and are not the same thing. The new units arrive with zero cost. Total cost is unchanged, spread over more units, and for tax purposes the acquisition date of bonus units is their own, not the original purchase date. Anything computing capital gains has to know the difference. We got this wrong initially by treating bonus units as a split, and the gains reports were subtly wrong for the folios that had them.</p>
<p><strong>Dividend payout</strong> is the simple one: cash leaves the scheme, NAV drops, the client gets a credit.</p>
<p><strong>Dividend reinvestment</strong> is the same event followed immediately by a purchase at the ex-dividend NAV, and it is the single largest source of reconciliation breaks I've dealt with. Everything hinges on which NAV the reinvestment uses and how the resulting fractional units are rounded. If your rounding differs from the registrar's by a fraction in the third decimal, you don't get one break, you get a permanent divergence that compounds with every subsequent reinvestment on that folio.</p>
<p><strong>Scheme mergers</strong> are the messy ones. Units in the merging scheme convert to units in the surviving scheme at a swap ratio, the old scheme code stops existing, and historical transactions still reference it. Old data cannot be rewritten to point at the new scheme, because then a statement for a period before the merger would show a scheme the client never held. The mapping lives alongside the ledger, and reporting resolves through it based on the date being reported.</p>
<p><strong>Segregated portfolios</strong>, or side-pocketing, is the one that's genuinely specific to this market and genuinely hard. When a debt scheme has an issuer default, the affected holding is segregated into a separate portfolio with its own scheme code. Clients wake up holding two things where they held one, and the segregated part often has a NAV of zero pending recovery.</p>
<p>Zero is not the same as worthless, and it is definitely not a realised loss. If your reporting treats it as either, you've told a client they lost money that may well come back years later. Side-pocketed holdings need their own display treatment and their own exclusion from return calculations, and that requirement reaches surprisingly far up the stack, into charts and XIRR and the summary tiles on a dashboard.</p>
<h2 id="they-are-ledger-entries-not-mutations">They are ledger entries, not mutations</h2>
<p>Every one of these is applied as an entry in the transaction ledger, never as an update to a holdings row. Holdings are derived by replaying the ledger in order, which is the same design the rest of the platform uses for ordinary transactions.</p>
<p>The reason is the same reason. Corporate actions arrive late, get revised, and occasionally get cancelled after being announced. A ratio gets corrected. An ex-date moves. If the action mutated a holdings row, reversing it means reconstructing a state you no longer have. As a ledger entry, a correction is another entry, the snapshots from that date forward are invalidated, and the position recomputes.</p>
<p>Idempotency deserves paranoia here. Feeds re-send corporate actions, sometimes days later, sometimes in a bulk file alongside new ones. Applying a 1:1 bonus twice quadruples a holding, and unlike a duplicated NAV it does not stand out as obviously wrong. Every action carries a natural key of scheme, action type and ex-date with a unique constraint behind it, and reapplication is a no-op unless the ratio has actually changed, in which case the previous entry is superseded rather than edited.</p>
<p>The ordering rule that saved us: corporate actions apply before transactions on the same date. A client redeeming on the ex-date of a bonus is redeeming from the post-bonus unit count. Getting that backwards produces a negative balance, which at least fails loudly, and the day it fails loudly is the day you find out your ordering was undefined.</p>
<h2 id="the-fix-for-the-doubled-portfolio">The fix for the doubled portfolio</h2>
<p>Valuation no longer runs against a unit count and a NAV independently. A holding is only valued with a NAV whose date is consistent with the unit count as of that date, and where the two are out of step, the position shows as pending revaluation rather than as a number.</p>
<p>It's less satisfying than a clever solution and it means an advisor occasionally sees "awaiting NAV" against a scheme on a bonus morning. Every advisor I've spoken to prefers that to the alternative, which is finding out from a client that the portfolio doubled overnight and then having to explain that it didn't.</p>
<p>Everything here sits on top of the ledger and reconciliation design I wrote about separately. If holdings in your system are a stored running total rather than something you derive, none of this is available to you, and corporate actions are where you find that out.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[Every table, and no guarantees]]></title>
            <link>https://ravipandey.com/articles/every-table-and-no-guarantees</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/every-table-and-no-guarantees</guid>
            <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>People ask which database and which ORM in the same breath, as though the two decisions carry similar weight. On a wealth platform they don't. One took about ten minutes and the other is still generating consequences.</p>
<h2 id="postgres-briefly">Postgres, briefly</h2>
<p>There wasn't much to decide. In a system where the central artifact is a ledger of financial transactions, you need <code>numeric</code> that behaves like decimal rather than a float that is approximately right. You need constraints the application cannot talk its way around. You want row-level security if tenant isolation is going to be enforced below the application. Partial and expression indexes turn out to matter enormously once real data arrives. And transactional DDL means a failed migration leaves you where you started rather than halfway.</p>
<p>The alternative in this domain isn't a different database. It's a worse version of this one, plus application code doing the parts you gave up.</p>
<p>So: Postgres, on node-postgres, and on to the decision that was actually interesting.</p>
<h2 id="drizzle-and-what-we-bought">Drizzle, and what we bought</h2>
<p>The schema is TypeScript. Migrations are generated from it, the instance is exposed through one global injection token, and every service injects it the same way.</p>
<p>The part that gets a reaction from other engineers is that there's no repository layer. No abstraction over the query builder, no interface between services and the database. Services write queries directly, against types inferred from the schema.</p>
<p>That's a hard coupling to one ORM, and we took it deliberately. What you get in exchange is that there's exactly one representation of a table in the codebase, and the types the compiler checks your queries against are generated from the same definition the migration came from. A repository layer would buy portability we will never use, at the cost of a second set of types that drift from the first, plus a translation layer everyone has to read through to answer what a query actually does.</p>
<p>I've worked on codebases with that layer. The abstraction is almost never exercised. Nobody swaps the database. What they do is maintain the interface, forever, in case somebody someday does.</p>
<p>The corollary is that when the ORM can't express something, there's nowhere for the workaround to hide. Which brings us to the bill.</p>
<h2 id="what-a-code-first-schema-cannot-say">What a code-first schema cannot say</h2>
<p>The migration generator emits only what the schema DSL can describe, and the DSL is a subset of what Postgres can do. In our case, five categories fall outside it: trigger functions, the triggers that use them, most raw CHECK constraints, row-level security along with its policies, and partial or expression indexes — anything with a <code>WHERE</code> clause or a <code>lower()</code> in it.</p>
<p>So those live in a hand-maintained SQL file, applied by its own command, with a check mode that reports drift and writes nothing. That file is the home for anything new of those kinds, and it's the only mechanical proof that a given database actually has them.</p>
<p>Which sounds like bookkeeping until you look at what's in the list. Tenant isolation. The append-only guarantee on the attribution table. The write-once guarantee on the audit tables. The most important integrity properties in the system are precisely the ones the schema language can't describe.</p>
<h2 id="the-category-that-is-genuinely-dangerous">The category that is genuinely dangerous</h2>
<p>There's a worse case than "the generator can't emit this", and it took us a while to see it.</p>
<p>For eight unique indexes, the generator emits something. It produces the right name, over the right columns, and drops the part that makes the index correct — the <code>WHERE</code> clause, the <code>lower()</code>, the null-handling. The result isn't missing. It's stricter than intended, and it already exists under the right name, so a conditional create leaves it alone.</p>
<p>Three of those were not academic:</p>
<p>A unique index on tenant and email, without its <code>WHERE email IS NOT NULL</code>, means only one staff row per tenant may have a null email. Staff email is nullable by design, and the bootstrap admin deliberately has none. The second one you create fails.</p>
<p>A unique index on admin email, without <code>lower()</code>, makes uniqueness case-sensitive, so the same address registers twice in different cases. Without its predicate on the deleted flag, a soft-deleted admin squats on their address permanently.</p>
<p>An index over a linkage table, without <code>WHERE validTo IS NULL</code>, means a relationship that legitimately ended and was later re-established collides with its own closed row.</p>
<p>Every one of those is a plausible bug report months later, in a component nobody would think to connect to a migration.</p>
<h2 id="why-this-needs-a-guard-when-a-missing-table-does-not">Why this needs a guard when a missing table does not</h2>
<p>A missing table is loud. The next query throws and somebody fixes it in five minutes.</p>
<p>Every object in that SQL file fails silently. A database without them has every table, every column and every foreign key — in our case a few hundred tables and several thousand columns. Seeding completes. The test suite passes, because most tests never touch a real database at all. Nothing anywhere reports a problem.</p>
<p>What's actually gone is that tenant-scoped queries return rows, just not only this tenant's rows. Attribution can be deleted. Audit history can be rewritten. The system behaves normally in every way you'd notice from the outside.</p>
<p>That asymmetry is the whole argument for the check running in CI and after every deploy. An integrity guarantee that fails loudly needs no ceremony. One that fails silently needs a mechanical prover, because human review will not catch its absence — there's nothing to see.</p>
<p>Related, and worth knowing if you use one of these tools: the generator does not read your database. It diffs the schema against a stored snapshot of what it last generated. Write a migration by hand and the snapshot goes stale, so the next generate cheerfully re-proposes everything that migration already did. We produced one forty-one-statement migration that would have dropped a table twice before anyone read it properly.</p>
<p>We also found two indexes present on the live database and in no migration at all. Somebody created them by hand, at some point, for some reason. Without the SQL file collecting them, they'd simply be absent from every freshly built environment, and nobody would know until behaviour diverged between two databases that were supposed to be identical.</p>
<h2 id="the-other-tax-migration-numbers">The other tax: migration numbers</h2>
<p>With several feature branches open at once, any of which may carry a schema change, sequential migration filenames collide constantly. Two people generate the same number in the same week and both are correct.</p>
<p>The rule we landed on is that a migration is provisional until it reaches the trunk and immutable forever after. On a branch its number is a working guess, renumbered freely by regenerating. Once merged, its number, filename and every byte of its SQL are frozen, because production walks that one chain forward and nothing else.</p>
<p>This isn't Drizzle's fault and you'd have it with any file-per-migration tool. It's worth planning for before it happens rather than during a release.</p>
<h2 id="would-i-choose-it-again">Would I choose it again</h2>
<p>Yes, and I'd set up the SQL file and its check on day one rather than after discovering the gap.</p>
<p>The generalisable point isn't about Drizzle specifically. Any code-first ORM defines a schema language that is a subset of what your database can express, and that subset is chosen for what's common across databases. Your integrity guarantees are, almost by definition, the uncommon parts: the partial index that encodes a business rule, the trigger that makes a table append-only, the policy that makes isolation real.</p>
<p>So the gap between what your ORM can generate and what your database can enforce is not an edge case you'll hit occasionally. It's exactly where the important things live. Find out what falls in that gap early, put it somewhere version-controlled, and build the thing that proves it's applied — because the failure mode is a database that has every table and enforces nothing.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[The model was confidently wrong about the syllabus]]></title>
            <link>https://ravipandey.com/articles/grounding-a-curriculum-generator</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/grounding-a-curriculum-generator</guid>
            <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>The first generated learning path I looked at properly was for CSIR NET chemical sciences, and it was good. Sensible progression, reasonable pacing, decent quizzes. It also contained a unit that hasn't been on that syllabus for years.</p>
<p>I only caught it because I've been running an exam-prep company since 2018 and that particular topic is one I have opinions about. A student wouldn't have caught it. That's the problem with these systems in one line: the failures don't look like failures, they look like content.</p>
<h2 id="why-the-obvious-pipeline-does-so-little">Why the obvious pipeline does so little</h2>
<p>The naive version is about forty lines. Chunk the corpus, embed it, embed the user's topic, pull the top five chunks, paste them into the prompt. It demos beautifully. It took me longer than I'd like to admit to articulate why it wasn't working.</p>
<p>Question answering is extractive. The answer sits in the corpus somewhere and retrieval's job is to find the paragraph. Curriculum generation is not that. "Build me a 12-week path for CSIR NET chemical sciences" has no answer sitting in any document. It's a structural task, and the model is genuinely good at structure: outlines, progression, prerequisite ordering. What it's bad at is knowing whether a topic is still examinable this year.</p>
<p>Retrieving against the whole request, then, is retrieving against nothing. No single passage answers it, and the top-k you get back is a grab bag that makes the prompt longer without making it truer.</p>
<h2 id="plan-first-ground-second">Plan first, ground second</h2>
<p>We split the job in two, and that change mattered more than everything else combined.</p>
<p>The first pass produces structure only. No retrieval, and a hard constraint that it emit topic titles and prerequisites, no content. The second pass walks that tree and writes each node separately, and that's where retrieval happens: one query per node, scoped to the node's topic, with the outline path as context.</p>
<p>LangGraph earned its place here, though not for the reason I expected. The appeal wasn't orchestration. It was that each node gets its own retrieval, its own generation and its own validity check, so a node that fails can be retried or dropped without collapsing the run. When you're generating forty lessons, the gap between "one lesson was rejected" and "the request failed" is the gap between a product and a demo.</p>
<div class="group/code relative"><pre class="language-python"><code class="language-python"><span class="token keyword">async</span> <span class="token keyword">def</span> <span class="token function">write_lesson</span><span class="token punctuation">(</span>state<span class="token punctuation">:</span> LessonState<span class="token punctuation">)</span> <span class="token operator">-</span><span class="token operator">&gt;</span> LessonState<span class="token punctuation">:</span>
    query <span class="token operator">=</span> build_query<span class="token punctuation">(</span>state<span class="token punctuation">.</span>topic<span class="token punctuation">,</span> state<span class="token punctuation">.</span>outline_path<span class="token punctuation">,</span> state<span class="token punctuation">.</span>exam<span class="token punctuation">)</span>
    hits <span class="token operator">=</span> <span class="token keyword">await</span> retrieve<span class="token punctuation">(</span>query<span class="token punctuation">,</span> k<span class="token operator">=</span><span class="token number">30</span><span class="token punctuation">)</span>
    context <span class="token operator">=</span> rerank<span class="token punctuation">(</span>query<span class="token punctuation">,</span> hits<span class="token punctuation">)</span><span class="token punctuation">[</span><span class="token punctuation">:</span><span class="token number">6</span><span class="token punctuation">]</span>

    <span class="token keyword">if</span> <span class="token builtin">max</span><span class="token punctuation">(</span>h<span class="token punctuation">.</span>score <span class="token keyword">for</span> h <span class="token keyword">in</span> context<span class="token punctuation">)</span> <span class="token operator">&lt;</span> GROUNDING_FLOOR<span class="token punctuation">:</span>
        <span class="token keyword">return</span> state<span class="token punctuation">.</span>copy<span class="token punctuation">(</span>status<span class="token operator">=</span><span class="token string">"uncovered"</span><span class="token punctuation">,</span> lesson<span class="token operator">=</span><span class="token boolean">None</span><span class="token punctuation">)</span>

    lesson <span class="token operator">=</span> <span class="token keyword">await</span> generate<span class="token punctuation">(</span>state<span class="token punctuation">.</span>topic<span class="token punctuation">,</span> context<span class="token punctuation">)</span>
    <span class="token keyword">return</span> state<span class="token punctuation">.</span>copy<span class="token punctuation">(</span>lesson<span class="token operator">=</span>lesson<span class="token punctuation">,</span> sources<span class="token operator">=</span><span class="token punctuation">[</span>c<span class="token punctuation">.</span><span class="token builtin">id</span> <span class="token keyword">for</span> c <span class="token keyword">in</span> context<span class="token punctuation">]</span><span class="token punctuation">)</span>
</code></pre><button type="button" aria-label="Copy code" class="absolute top-3 right-3 flex items-center gap-1 rounded-md bg-zinc-800/80 px-2 py-1 text-xs font-medium text-zinc-300 opacity-0 transition group-hover/code:opacity-100 hover:bg-zinc-700 hover:text-zinc-100 focus-visible:opacity-100 max-sm:opacity-100 dark:bg-zinc-700/60 dark:hover:bg-zinc-700"><svg viewBox="0 0 16 16" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="h-3.5 w-3.5 stroke-current"><path d="M5.75 4.75h5.5v6.5h-5.5v-6.5Z"></path><path d="M10.25 4.75V3.25h-6.5v6.5h1.5"></path></svg>Copy</button></div>
<p><code>status="uncovered"</code> is the line I'd argue for hardest. A node that can't find support for its topic returns nothing and says so. It doesn't write the lesson anyway.</p>
<h2 id="chunking">Chunking</h2>
<p>We used fixed token windows at first, because that's what every tutorial does. Moving to heading-aware chunks improved retrieval more than any model change we made: split on document structure, keep sections whole where they fit, and prepend the heading path to the chunk text before embedding.</p>
<p>The heading path is the actual trick. A chunk beginning "Unit 4 › Electrochemistry › Nernst equation" embeds somewhere meaningfully different from the same paragraph naked, and it matches a query built from an outline node almost by construction. It costs nothing. It is string concatenation.</p>
<p>Sibling context helped too. Each chunk stores the ids of the ones before and after it, so a retrieved chunk can pull its neighbours in. Textbooks and syllabus documents are full of sentences that only mean anything alongside the paragraph above them.</p>
<h2 id="the-eval-set">The eval set</h2>
<p>For about three weeks we improved the pipeline by reading outputs and going "hmm, better". That isn't improvement, it's mood.</p>
<p>Building an eval set fixed it and was much less work than I'd feared. Around 120 queries taken from real topic titles, each labelled with the corpus sections that ought to come back for it. Two of our subject teachers did the labelling in an afternoon and a half. Then one number to watch: recall@10.</p>
<p>We started near 0.62 and got to about 0.88. Heading-aware chunking was the biggest single jump. Hybrid search was next, and it wasn't close: dense embeddings are weak on exactly the terms that matter here, a scheme name, an author, a specific reaction, while BM25 nails those and is hopeless at paraphrase, so running both and fusing the rankings beat either alone. Then a cross-encoder rerank from the top 30 down to 6, slower per query and worth it, because the generation prompt now receives six chunks that are about the topic rather than six that are vaguely nearby.</p>
<p>None of that involved touching the vector database. The vector database is the least interesting component in a RAG system and it takes up a wildly disproportionate share of the conversation about them.</p>
<h2 id="retrieval-only-fixes-whats-retrievable">Retrieval only fixes what's retrievable</h2>
<p>This is the honest limit, and it's where the syllabus bug came from.</p>
<p>Good retrieval hands the model the right context when the right context exists. Where the corpus doesn't cover something, no amount of retrieval work helps. The model writes a fluent, plausible, unsupported lesson that reads exactly like the correct ones.</p>
<p>So there's a verification pass. After a lesson is generated we extract its factual claims and check each against the chunks retrieved for it, using a small cheap model for entailment. Claims that aren't supported get the lesson marked for review instead of published.</p>
<p>It isn't clever and it doesn't catch everything. What it catches is the specific failure I care about most, content that drifted from the source material while sounding entirely reasonable. A learner can't detect that. Neither could I, on topics I don't personally know.</p>
<p>The other half is abstention. When a topic genuinely isn't in the corpus, the right output is to say so, and getting a language model to do that reliably is mostly about making it structurally possible: the uncovered branch in the graph, a floor on retrieval score, and a prompt that offers refusal as a real option rather than an apology. Models will refuse when refusal is a shape the system can accept. They won't when the only path out is a lesson.</p>
<h2 id="making-a-minute-feel-like-seconds">Making a minute feel like seconds</h2>
<p>A full path runs to roughly forty lessons. Sequentially that's minutes, and nobody waits minutes.</p>
<p>Node generation runs in parallel behind a bounded semaphore, and retrieval is cached per topic, which matters more than it sounds because paths for related exams overlap heavily. The outline streams to the UI as soon as it exists, so a learner watches their path assemble while lessons fill in behind it. The wait they actually experience is the outline, a few seconds, not the run.</p>
<p>At around 50 concurrent learners the bottleneck was never our infrastructure. It was provider rate limits, which meant a queue with per-user fairness so that one person generating three paths doesn't stall everybody else.</p>
<h2 id="if-i-were-starting-again">If I were starting again</h2>
<p>I'd build the eval set before the pipeline. Before choosing a database, before any prompt engineering. A hundred labelled queries is an afternoon of somebody's time and it converts every later decision from an argument into a measurement, which is worth more than it sounds when you have three people with three opinions about chunk size.</p>
<p>The other thing is less about engineering. Get a domain expert to read the first hundred outputs properly, not as a spot check. The bug that started all of this was a single line in a lesson that was otherwise completely fine, and no metric we had was ever going to find it.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[The feature I deleted after shipping it]]></title>
            <link>https://ravipandey.com/articles/reading-a-roadmap-as-a-business-case</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/reading-a-roadmap-as-a-business-case</guid>
            <pubDate>Sat, 18 Jan 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>In 2021 I spent about six weeks building a discussion forum into Egxam. Threads, replies, moderation tools, notifications, the lot. It shipped, it worked, and roughly forty people used it. Nine months later I deleted it.</p>
<p>The deletion is the part I think about. Not because the feature was a bad idea in the abstract, but because at no point before building it had I written down what would have to be true for it to be worth six weeks. If I had, the answer would have been something like "several hundred students posting weekly", and I'd have known within a fortnight of launch that it wasn't going to happen. Instead I let it sit there for most of a year, because deleting something you built is a different kind of decision from not building it, and much harder.</p>
<p>That's the thing running a company teaches you that reviewing tickets doesn't. The cost of a feature is not the six weeks. It's the six weeks plus every subsequent month of maintenance, plus the support burden, plus the space it takes up in a UI that other things now have to work around, plus whatever you didn't build instead. Engineers are quite good at estimating the first term and tend not to price the rest at all.</p>
<p>I catch myself now asking a question in planning that used to annoy me when other people asked it: what happens if we don't do this? Not as a challenge. It's genuinely useful information. Sometimes the answer is that a client walks, or a compliance deadline passes, and then the conversation is over and we build it. Often the answer is a shrug, and a shrug is data.</p>
<p>The trap on the other side is treating this as an argument for always taking the cheap option, which it isn't. I've made that mistake too.</p>
<p>On a school ERP I once argued successfully for a quick fix to a fee-collection edge case rather than reworking how the module handled part payments. It was the right call on effort. It was the wrong call on everything else, because fee collection is the module the school's accounts team lives in during admission season, and the quick fix meant they had to remember a workaround. Which they did, for a while, and then a new person joined and didn't. The cost landed months later on someone I never met, and it landed as a phone call to support at the worst possible time of year.</p>
<p>The useful distinction isn't cheap versus thorough. It's whether the thing you're building sits on a path somebody walks every day. A rough edge on a feature used once a quarter by an administrator is a rough edge. A rough edge in the daily path is a tax, collected forever, from people who don't get a say.</p>
<p>The other thing founding a company changes is how you hear the word "requirement". When the person asking for a feature is a client, or a stakeholder, or a sales lead, the temptation is to treat the request as a specification and get on with it. What they've actually given you is a solution they've already designed in their head, and the problem behind it is usually still unstated.</p>
<p>I've had a request for an export button turn out to be someone reconciling two systems manually every morning, where the real answer was a scheduled job neither of us had discussed. I've also had requests where digging for the underlying problem was a waste of everyone's time and the client just wanted the button. Knowing which is which is mostly a matter of asking what they'll do with it, once, and listening to whether the answer has a person and a time of day in it.</p>
<p>None of this makes the estimates better. I'm as bad at estimating as I ever was. It changes what gets estimated, which turns out to matter more.</p>
<p>The forum, incidentally, was replaced by a WhatsApp group that one of our teachers set up on her own initiative. It has more activity than the forum ever did, cost nothing, and I have no control over it whatsoever. I've made my peace with that.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[Reconciliation is a feature, not a support ticket]]></title>
            <link>https://ravipandey.com/articles/reconciliation-is-a-feature</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/reconciliation-is-a-feature</guid>
            <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>An advisor called us on a Tuesday because a client's portfolio showed ₹4,18,000 in our dashboard and ₹4,17,600 on the statement the RTA had sent them. Four hundred rupees. On a portfolio that size it rounds to nothing, and it was still the only thing either of them wanted to talk about.</p>
<p>They were right to care. Nobody trusts a number that's close.</p>
<p>The difference turned out to be one scheme valued at the previous day's NAV, because that day's file landed after our valuation job ran and nothing went back to fix it. The fix took an hour. Working out why the system permitted it took considerably longer, and most of what I know about financial back-office software came out of that stretch.</p>
<h2 id="the-feed-is-a-series-of-claims">The feed is a series of claims</h2>
<p>The mental model that helped most was giving up on "today's NAV" as a fact we fetch. What we have is a stream of assertions from outside parties, each with a time we received it, each capable of being superseded later.</p>
<p>NAV files arrive late. They arrive twice, identical, because somebody re-ran an export. They arrive revised, same scheme and same date, a different number, days after the fact. Once we got a file where the date column used a different format for exactly the rows belonging to one AMC.</p>
<p>So we stopped overwriting anything. NAV history is append-only:</p>
<div class="group/code relative"><pre class="language-sql"><code class="language-sql"><span class="token keyword">create</span> <span class="token keyword">table</span> nav_history <span class="token punctuation">(</span>
  id            bigserial <span class="token keyword">primary</span> <span class="token keyword">key</span><span class="token punctuation">,</span>
  scheme_code   <span class="token keyword">text</span>        <span class="token operator">not</span> <span class="token boolean">null</span><span class="token punctuation">,</span>
  nav_date      <span class="token keyword">date</span>        <span class="token operator">not</span> <span class="token boolean">null</span><span class="token punctuation">,</span>
  nav           <span class="token keyword">numeric</span><span class="token punctuation">(</span><span class="token number">18</span><span class="token punctuation">,</span><span class="token number">6</span><span class="token punctuation">)</span> <span class="token operator">not</span> <span class="token boolean">null</span><span class="token punctuation">,</span>
  source        <span class="token keyword">text</span>        <span class="token operator">not</span> <span class="token boolean">null</span><span class="token punctuation">,</span>
  received_at   timestamptz <span class="token operator">not</span> <span class="token boolean">null</span> <span class="token keyword">default</span> <span class="token function">now</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span>
  superseded_by <span class="token keyword">bigint</span>      <span class="token keyword">references</span> nav_history<span class="token punctuation">(</span>id<span class="token punctuation">)</span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>

<span class="token keyword">create</span> <span class="token keyword">unique</span> <span class="token keyword">index</span> nav_history_current
  <span class="token keyword">on</span> nav_history <span class="token punctuation">(</span>scheme_code<span class="token punctuation">,</span> nav_date<span class="token punctuation">)</span>
  <span class="token keyword">where</span> superseded_by <span class="token operator">is</span> <span class="token boolean">null</span><span class="token punctuation">;</span>
</code></pre><button type="button" aria-label="Copy code" class="absolute top-3 right-3 flex items-center gap-1 rounded-md bg-zinc-800/80 px-2 py-1 text-xs font-medium text-zinc-300 opacity-0 transition group-hover/code:opacity-100 hover:bg-zinc-700 hover:text-zinc-100 focus-visible:opacity-100 max-sm:opacity-100 dark:bg-zinc-700/60 dark:hover:bg-zinc-700"><svg viewBox="0 0 16 16" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="h-3.5 w-3.5 stroke-current"><path d="M5.75 4.75h5.5v6.5h-5.5v-6.5Z"></path><path d="M10.25 4.75V3.25h-6.5v6.5h1.5"></path></svg>Copy</button></div>
<p>A revision inserts a new row and points the old one at it. The partial unique index means there is exactly one live NAV per scheme per date, enforced by the database rather than by whichever import path happened to run. And because the old value is still there, when an advisor asks why a valuation for a past date changed between Monday and Wednesday, there's an answer instead of a shrug.</p>
<p>The same shape applies to nearly every external feed. Prices, corporate actions, KYC status. Every time I've been tempted to store current state and discard what it replaced, I've regretted it inside a quarter.</p>
<h2 id="imports-are-all-or-nothing">Imports are all-or-nothing</h2>
<p>Every file lands in a staging table first, tagged with a batch id, and nothing touches live tables until the whole batch validates. Half a file applied is the worst outcome available to you: a portfolio that is wrong in a way nobody can see, versus a job that failed loudly and got re-run.</p>
<p>Validation runs in layers, cheapest first.</p>
<p>The file itself: expected row count against the header, the date the file claims to be for, and whether we've already ingested a file with this checksum. That last check has saved us more than anything else in the pipeline. Operations teams re-upload files. They just do.</p>
<p>Then rows: every column parses, dates are real dates, NAV is positive, scheme code exists in our master.</p>
<p>Then the file against itself: no duplicate scheme codes, and where a file carries totals, the totals add up.</p>
<p>Then the file against what we already know. A NAV that has moved more than a threshold since the last known value for that scheme gets flagged rather than rejected. Debt funds don't move 8% overnight, equity funds occasionally do, and a hard reject would have blocked a legitimate import on a bad day. A flag put it in front of somebody who could say yes.</p>
<p>Re-running an import has to be safe. Ours keys on <code>(scheme_code, nav_date, source)</code>, does nothing when the incoming value matches, supersedes when it doesn't. That property is what lets you tell operations "just upload it again" without a knot in your stomach.</p>
<h2 id="numbers">Numbers</h2>
<p>Money is <code>numeric</code> in Postgres. Not <code>float</code>, not <code>double precision</code>, and this isn't a style opinion: <code>0.1 + 0.2</code> is why your reconciliation report has a line for ₹0.000001.</p>
<p>The trap on a TypeScript stack sits one layer up. Postgres drivers hand <code>numeric</code> back as a string, deliberately, because a JS number is a float64 and cannot hold what <code>numeric(18,6)</code> holds. If a helper somewhere in your codebase calls <code>Number(row.nav)</code> to make a type error go away, you have thrown the guarantee away at the exact boundary that was protecting you.</p>
<p>We ran decimal arithmetic end to end. Strings out of the database, a decimal library in the service layer, strings over the API, formatting only at the last step in the UI. Every conversion to <code>number</code> is a lint error. That rule looked pedantic when we wrote it and has never cost us anything since.</p>
<p>Units get the same treatment. Mutual fund units carry three or four decimals depending on the AMC, and a rounding rule that's fine for display will slowly desynchronise a holding from the registrar's books over a few hundred transactions.</p>
<h2 id="backdated-transactions-which-is-where-i-lost-the-most-time">Backdated transactions, which is where I lost the most time</h2>
<p>The obvious design is a <code>holdings</code> table you update as transactions come in. Fast, simple, and it falls apart the first time a transaction arrives late.</p>
<p>Which it will. A purchase executes on the 3rd, the feed carries it on the 6th, and every valuation you computed on the 4th and 5th is now wrong. If holdings are a mutable running total you have no way back. Undoing it means knowing what the balance was before a transaction that hadn't been applied yet, and your schema cannot answer that question.</p>
<p>So transactions are the ledger, holdings are derived, and snapshots are only a cache:</p>
<div class="group/code relative"><pre class="language-ts"><code class="language-ts"><span class="token comment">// A backdated transaction invalidates every snapshot from its date forward.</span>
<span class="token keyword control-flow">await</span> tx<span class="token punctuation">.</span><span class="token property-access">snapshot</span><span class="token punctuation">.</span><span class="token method function property-access">deleteMany</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  where<span class="token operator">:</span> <span class="token punctuation">{</span> folioId<span class="token punctuation">,</span> asOf<span class="token operator">:</span> <span class="token punctuation">{</span> gte<span class="token operator">:</span> txn<span class="token punctuation">.</span><span class="token property-access">tradeDate</span> <span class="token punctuation">}</span> <span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token keyword control-flow">await</span> <span class="token function">enqueue</span><span class="token punctuation">(</span><span class="token string">'recompute-holdings'</span><span class="token punctuation">,</span> <span class="token punctuation">{</span> folioId<span class="token punctuation">,</span> <span class="token keyword module">from</span><span class="token operator">:</span> txn<span class="token punctuation">.</span><span class="token property-access">tradeDate</span> <span class="token punctuation">}</span><span class="token punctuation">)</span>
</code></pre><button type="button" aria-label="Copy code" class="absolute top-3 right-3 flex items-center gap-1 rounded-md bg-zinc-800/80 px-2 py-1 text-xs font-medium text-zinc-300 opacity-0 transition group-hover/code:opacity-100 hover:bg-zinc-700 hover:text-zinc-100 focus-visible:opacity-100 max-sm:opacity-100 dark:bg-zinc-700/60 dark:hover:bg-zinc-700"><svg viewBox="0 0 16 16" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="h-3.5 w-3.5 stroke-current"><path d="M5.75 4.75h5.5v6.5h-5.5v-6.5Z"></path><path d="M10.25 4.75V3.25h-6.5v6.5h1.5"></path></svg>Copy</button></div>
<p>Recomputation is a job, never a request. Same-day ordering is defined explicitly, purchases before redemptions, then trade time, then transaction id as a tiebreaker, because "the order they arrived in" is not deterministic and two runs that disagree is worse than either one being wrong.</p>
<p>Three things about this that I only learned by getting them wrong.</p>
<p>Two recomputes for the same folio must not run concurrently. Obvious in hindsight. We now take a Postgres advisory lock keyed on the folio id at the top of the job, and a second worker that can't get it re-queues with a short delay rather than waiting on the lock, because holding a connection open to wait is how you find out your pool is smaller than you thought.</p>
<p>Bulk files cause recompute storms. One backdated file with a few thousand transactions across several hundred folios enqueues several hundred recomputes, most of them redundant because the same folio appears eleven times in the file. The import now collects the earliest affected date per folio across the whole batch and enqueues once per folio at the end. Obvious once you've watched a queue depth chart go vertical at 2am.</p>
<p>And statements have to be reproducible. This is the one that genuinely surprised me. An advisor generates a portfolio statement on the 5th and emails the PDF to a client. A backdated transaction lands on the 6th. Regenerate the same statement for the same period and it now shows different numbers, correctly. But the client is holding the first PDF. If you cannot reproduce exactly what you sent, you cannot have that conversation at all. So every generated statement stores the ledger version it was computed from, and regenerating an old statement replays the ledger as it stood at that point rather than as it stands now.</p>
<p>Cost basis is where all this pays for itself. FIFO across a folio with backdated entries and fractional units is not something you can maintain incrementally with any confidence. Derived from an ordered ledger, it's a fold.</p>
<h2 id="breaks-belong-on-a-screen">Breaks belong on a screen</h2>
<p>For a long time reconciliation was an alert. A job compared our computed holdings against the registrar's statement and emailed the tech team when they disagreed.</p>
<p>That's backwards. Breaks aren't exceptions in this domain, they're routine. A transaction the RTA has and we don't. A dividend reinvestment processed a day differently. A unit count off in the fourth decimal from a rounding mismatch. Some need an engineer. Most need an operations person who knows the AMC.</p>
<p>So we made it a screen. A daily run produces break records with a type, both values, the difference and a status, and operations works the queue: acknowledge, annotate, resolve, escalate. The count of open breaks is a number somebody actually owns.</p>
<p>Two things came out of that which I didn't expect. Ops stopped raising tickets for routine breaks because they could simply handle them, so the ones that reached me were genuinely mine. And the break log turned out to be the best bug report I've ever had. The same break type against the same AMC every month is an integration defect, and that pattern is invisible when it arrives one email at a time.</p>
<p>As for the four hundred rupees: valuation no longer runs at a fixed time. It runs when the feed for a scheme's date is complete, and a scheme whose NAV hasn't landed is marked stale in the UI rather than silently carried forward at yesterday's price. Advisors would rather see "awaiting NAV" than a number they'll have to explain later.</p>
<p>Corporate actions deserve their own piece. Splits, bonuses and dividend reinvestments each break a naive holdings model in a slightly different way, and that's a longer story.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[The review comment I regret]]></title>
            <link>https://ravipandey.com/articles/reviews-that-make-the-team-better</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/reviews-that-make-the-team-better</guid>
            <pubDate>Sat, 24 May 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Early on as a lead I left a comment that said, in full: "why not just use a map here?"</p>
<p>Six words, no malice in them, and I remember the reply arriving forty minutes later with a rewritten function and a slightly anxious "is this better?". The change was fine either way. What I had actually communicated, without meaning to, was that a senior person had looked at their work and found it wanting, and they'd spent forty minutes on something that didn't matter to get back to neutral.</p>
<p>The comment wasn't wrong. It was unlabelled. The reviewer knows whether they're blocking a merge, suggesting an improvement, or thinking out loud. The author has no idea, and in the absence of a signal they will assume the strongest reading. That asymmetry is most of what makes code review feel worse than it needs to.</p>
<p>So now I label. Three words at the front of the comment, and the convention costs nothing:</p>
<p><strong>Blocking</strong> means I think this is a defect or a real risk and I'd like it addressed before merge. <strong>Suggestion</strong> means I'd have done it differently, take it or leave it, and I genuinely mean leave it. <strong>Musing</strong> means I'm thinking about something adjacent and you should feel free to ignore me entirely.</p>
<p>The proportions are instructive. When I started tracking my own comments for a month, blocking ones were under a fifth. The rest was preference dressed as feedback, and before I was labelling them, all of it read as blocking to the person on the other end.</p>
<h2 id="review-the-change-not-the-version-youd-have-written">Review the change, not the version you'd have written</h2>
<p>The habit I've had to unlearn hardest is reading a pull request as a diff against the implementation in my head.</p>
<p>There are usually several reasonable ways to solve a problem. If the author picked one and it works and it's maintainable, the fact that I'd have picked another is not a finding. It's a preference, and stating it as anything else costs you credibility for the times when you do have something real.</p>
<p>The question I try to hold instead is whether this change solves the problem it claims to. Which means the first thing I read is not the code, it's the ticket, and if I can't tell from the pull request what problem it's solving I ask that before I look at a single line. A surprising number of review rounds are two people disagreeing about the requirement while appearing to disagree about a function.</p>
<p>The things I do treat as blocking are narrow. Correctness. Anything touching money, permissions or tenancy. Anything that will be expensive to change later, meaning data models and public interfaces, because those calcify while implementations don't. A missing test on a path that has broken before. That list is short deliberately.</p>
<h2 id="size-is-a-review-problem-before-its-an-engineering-problem">Size is a review problem before it's an engineering problem</h2>
<p>A four-hundred-line pull request gets a real review. A two-thousand-line one gets a rubber stamp with three comments about naming, and everybody involved knows it.</p>
<p>I don't have a hard limit because hard limits get gamed, but when something arrives that large the useful move is usually to ask for the review to be split by concern rather than by file. Often the author already knows which parts are risky and which are mechanical, and they'll tell you if you ask. "Which bit do you want me to look at hardest" is the highest-yield question I know for a big change.</p>
<p>The related thing is timing. A review that arrives two days after the pull request is not a review, it's an obstacle. I'd rather give a shallower review within a few hours than a thorough one on Thursday for something opened on Tuesday, because by Thursday the author has moved on, rebuilt context to answer me, and learnt that opening a pull request means waiting.</p>
<h2 id="approve-with-comments-more-than-you-think">Approve with comments, more than you think</h2>
<p>Blocking a merge over things that aren't blocking is the most common failure I see in teams that take review seriously. It comes from a good instinct and it trains people to batch up changes, avoid review, and treat the process as a gate to be passed rather than help to be sought.</p>
<p>Approving with unresolved suggestions requires trusting that the author will use their judgement, which is uncomfortable the first few times and then becomes the normal state of a team that's working. If I can't trust someone's judgement on a suggestion-level comment, that's a conversation to have directly, not something to enforce through the merge button.</p>
<h2 id="not-everything-should-be-a-pull-request">Not everything should be a pull request</h2>
<p>The reviews that go worst are the ones where the disagreement is architectural and it surfaces after the code is written. By then the author has spent three days on it and every comment costs them work, so the discussion is no longer about the best design, it's about sunk cost with both parties pretending otherwise.</p>
<p>The fix is upstream and it's cheap: fifteen minutes and a diagram before anything is built, for anything that touches more than one module. I don't always remember to ask for it. Every time I've skipped it on something significant, I've paid for it in a review thread that ran to forty comments and left somebody demoralised.</p>
<p>For junior developers I've also started reviewing in person more, sitting together rather than commenting. It's slower and it doesn't scale, and it teaches about five times as much per round, because the useful part isn't the finding, it's the reasoning that produced it. Written comments transmit conclusions. They're very bad at transmitting how you got there.</p>
<p>I did apologise for the map comment, eventually, and probably made it weirder in the process. But the labelling convention came out of it, and the team uses it now, including on my pull requests. Being told "blocking: this breaks on an empty array" by someone I hired is the most direct evidence I have that any of this worked.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[It does not decide who gets hired]]></title>
            <link>https://ravipandey.com/articles/scoring-candidates-with-an-llm</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/scoring-candidates-with-an-llm</guid>
            <pubDate>Sat, 14 Feb 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>The first question anybody asks about Interview Pro is whether it decides who gets hired.</p>
<p>It doesn't, and I've come to think the interesting engineering is almost entirely in the work of making sure it can't. The easy version of this product exists in an afternoon: transcript in, "rate this candidate out of ten" out, sort descending. It would demo well and it would be indefensible, both ethically and as a piece of software, because you'd have no way of knowing what the number meant.</p>
<p>What we built instead does something narrower. It reads a video interview transcript and a coding submission, answers a fixed set of rubric questions about them, and cites the specific moment that supports each answer. A human reads that and makes a decision. Across a sample of fifty candidates it cut first-pass review time by somewhere between sixty and seventy per cent, and I'll come back to what that number does and doesn't mean.</p>
<h2 id="extraction-not-judgement">Extraction, not judgement</h2>
<p>The design rule everything follows from: the model is never asked for an opinion it can't point at.</p>
<p>A criterion is not "communication skills". It's a question with a locatable answer, like whether the candidate stated their assumptions before starting the problem, or whether they described a trade-off in their chosen approach. The output for each criterion is a finding, a confidence, and a quoted span from the transcript or a range of lines from the submitted code.</p>
<p>If it can't cite, it returns not-assessed. That happens more than you'd expect and it's the correct outcome, not a failure. A thirty-minute interview simply doesn't contain evidence for every rubric line, and a system that produces a score for every criterion regardless is inventing the difference.</p>
<p>The reviewer's interface is built around the citations rather than the scores. Clicking a finding jumps to that point in the video. In practice reviewers spend their time confirming or dismissing evidence, which is a much better use of a human than reading a transcript top to bottom, and it means a wrong finding gets caught in seconds rather than propagating into a decision.</p>
<h2 id="the-variance-nobody-puts-in-the-demo">The variance nobody puts in the demo</h2>
<p>Run the same submission through twice and you get different results. Not wildly different, but different enough to matter if you're sorting people by a number.</p>
<p>Temperature zero reduces it and does not eliminate it. Once we started measuring, criteria split into two groups: concrete ones, like whether tests pass or whether a specific approach was named, were stable to the point of boredom, while inferential ones, like whether an explanation was clear, moved between runs often enough that a single run's score wasn't trustworthy.</p>
<p>Two consequences. Inferential criteria report a band rather than a number, because a band is an honest description of what we actually know. And every criterion runs several times, with disagreement between runs surfaced to the reviewer as a flag rather than averaged away. Averaging would have hidden exactly the cases where a human most needs to look.</p>
<p>I'd encourage anyone building this kind of thing to measure their own variance before shipping. It takes an afternoon, it is not in anyone's benchmark, and it changes what you're willing to claim.</p>
<h2 id="transcription-is-a-fairness-problem">Transcription is a fairness problem</h2>
<p>This is the part I'd most want people to take away.</p>
<p>Automatic speech recognition does not perform equally across accents. That's well documented, and for a product where the input is a recording of somebody speaking, it means the quality of the evidence the model reasons over varies systematically with who the candidate is. A garbled transcript produces weaker findings, weaker findings produce a thinner case, and the person disadvantaged is the one whose speech the recogniser handled worst.</p>
<p>You cannot fix that with a prompt. We do what we can: names and identifying details are stripped before assessment, word-level confidence from the recogniser is carried through and low-confidence spans are marked, and any finding resting on a low-confidence span is flagged for the reviewer to listen to directly rather than read. The audio is always one click away and reviewers are told, in the interface, not to rely on the transcript for anything they're unsure about.</p>
<p>That is mitigation, not a solution. The honest position is that this class of system has a bias surface that isn't in the model at all, it's in the pipeline ahead of it, and anyone deploying one should know that before a candidate does.</p>
<h2 id="the-code-half-is-easier">The code half is easier</h2>
<p>Assessing the coding exercise is a much better-behaved problem, because most of it isn't a language model question at all.</p>
<p>Tests pass or they don't. Complexity is measurable. Whether the submission handles the empty input is something you check by running it. All of that is deterministic, it runs in a sandbox, and the results are facts.</p>
<p>The model's job on that side is summarising the approach for a reviewer and noting things a test suite won't catch: a variable naming scheme that suggests confusion about the domain, a comment explaining a trade-off, an abandoned approach visible in the editor history. Useful, qualitative, and clearly labelled as commentary rather than as measurement.</p>
<p>Keeping that boundary visible in the interface mattered more than I expected. When deterministic results and model commentary are styled the same way, reviewers weight them the same way, and they shouldn't.</p>
<h2 id="what-the-number-actually-means">What the number actually means</h2>
<p>Sixty to seventy per cent, across fifty candidates, measures reviewer time on first-pass screening. That's it.</p>
<p>It does not mean decisions improved. We didn't measure that, and honestly we couldn't have with the data available: it would need the counterfactual of the same candidates screened without the tool and a hiring outcome to compare against, months later, with enough volume for the comparison to mean anything.</p>
<p>So what I can defend is that reviewers got through screening substantially faster, and that they reported spending their time on stronger candidates rather than on rejecting obvious mismatches. What I can't tell you is whether the people hired are better. Anyone selling you that claim on a sample of fifty is selling you something.</p>
<p>The rule the whole product rests on, and the one I'd defend hardest, is that nothing is ever auto-rejected. The system orders a queue and drafts evidence. A person makes every decision about a person. That constraint costs us the most impressive number we could have put on a slide, and given what these systems get wrong and who they get it wrong about, I think it's the only defensible way to build one.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[Twenty-four modules, two processes]]></title>
            <link>https://ravipandey.com/articles/twenty-four-modules-two-processes</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/twenty-four-modules-two-processes</guid>
            <pubDate>Fri, 08 May 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Someone asked me, in a room where it was a reasonable thing to ask, why a platform with two dozen modules isn't two dozen services.</p>
<p>It's a fair question. Written out, the scope invites it: identity and access, tenancy, client onboarding and KYC, master data, order management, transaction processing, corporate actions, holdings and analytics, RM and customer dashboards, reporting, revenue and incentives, CRM, advisory, communications. Across twelve financial products, from mutual funds and direct equity through AIFs, PMS, structured products, NPS and insurance. That reads like an architecture diagram with a lot of boxes in it.</p>
<p>It's one NestJS application. Around three and a half thousand TypeScript files, one Postgres database, and two processes. The decision came down to three things, and only one of them is technical.</p>
<h2 id="the-ledger-doesnt-have-a-seam-in-it">The ledger doesn't have a seam in it</h2>
<p>Look at the middle of that list in order: order, transaction processing, corporate actions, holdings, revenue.</p>
<p>Those aren't five domains that occasionally talk. They're five views of one thing. An order becomes a confirmed transaction, the transaction moves a position, the position is valued against a NAV, a corporate action rewrites the position, and the commission owed to a distributor is computed downstream of all of it. A single backdated transaction reaches every one of them.</p>
<p>Make each a service and every operation that used to be a database transaction becomes a distributed one. Applying a corporate action that arrived late means coordinating writes across three services with no shared transaction boundary. The standard answer is a saga with compensating actions, and I want to be precise about what that means here: you are building a system where a partial failure leaves a client's holdings temporarily wrong, and relying on a compensation step to put them right.</p>
<p>For a shopping cart, fine. For units in somebody's portfolio, that window is a period during which a relationship manager can open a screen and read a number that isn't true. There is no good way to explain that to an advisor.</p>
<p>So the strongest argument for keeping this together isn't performance or simplicity. It's that a consistency boundary is a real thing in this domain, it wraps most of the platform, and cutting it into services doesn't remove the requirement. It relocates it into application code, where it's harder to see and impossible to enforce.</p>
<h2 id="microservices-solve-a-problem-we-dont-have">Microservices solve a problem we don't have</h2>
<p>The second reason: microservices are an organisational solution.</p>
<p>They exist so that many teams can deploy independently without coordinating. That's a genuine problem and the architecture genuinely solves it, at the cost of network calls where you had function calls, distributed tracing where you had a stack trace, versioned contracts between things that used to be a shared type, and an operational surface that needs people to run it.</p>
<p>We don't have that problem. We have a small team building deep domain complexity, and the difficulty here isn't coordinating deploys. It's that corporate action processing across twelve asset classes is genuinely intricate, that AIF drawdowns and bond coupon accrual and SIP mandates all behave differently, and that being wrong is unacceptable. Adopting an architecture designed for inter-team coordination when your bottleneck is domain modelling is a permanent tax against a problem you'd like to have one day.</p>
<p>There's a glib version of this that goes "you're not Netflix". The useful version is: pick the architecture that addresses your actual constraint. Ours was that most of the platform had to exist at once, working as one system, built by a team that fits in a room.</p>
<h2 id="where-the-software-has-to-run">Where the software has to run</h2>
<p>The third reason would settle it on its own, and it's the one that isn't visible from outside the product.</p>
<p>The platform ships in more than one deployment mode. There's the multi-tenant SaaS, and there are white-labelled enterprise deployments for firms that want it in their own environment, some of which have their own infrastructure policies.</p>
<p>A monolith in that world is one artifact, a database, and a runbook somebody can read in a morning. The same product as two dozen services is a container orchestrator, a service mesh, distributed tracing, a message broker, and two dozen sets of health checks and secrets, all to be installed, monitored and upgraded inside somebody else's data centre by somebody else's ops team.</p>
<p>That isn't a harder deployment. It's a different product, with a different sales cycle and a different support contract.</p>
<h2 id="modular-which-is-the-part-that-matters">Modular, which is the part that matters</h2>
<p>One deployable is not one big file, and that distinction is the whole design.</p>
<p>Modules own their area and talk through published interfaces rather than reaching into each other. Business logic never imports the database schema directly; it injects the connection through a single token, so schema files stay declarations and nothing downstream depends on table shapes it doesn't own.</p>
<p>The pattern I'd point to as the clearest example is file storage. Every uploaded object goes to a tenant-scoped key, and there is exactly one function that builds those keys. Nothing templates a path inline. Signed URL generation and deletion take the requesting tenant and check it against the key before doing anything. That's one choke point, in one file, instead of a rule about paths that every developer has to remember.</p>
<p>Tenant isolation works the same way, except the enforcement is below the application entirely. Row-level security policies live in the database, and because the ORM's migration generator can't express RLS, triggers or partial indexes, those objects are applied by a separate step with a CI check that reports drift. A policy that quietly stopped being applied would otherwise be invisible: you'd have every table, and nothing enforcing anything.</p>
<p>Some of the boundaries are enforced by tests. There's an invariant spec that fails the build if anyone reintroduces implicit permission grants on user creation, because that rule is the kind of thing a reasonable person would undo while fixing something else, six months after the reasoning was written down. A test that fails the build is a stronger statement than a comment.</p>
<h2 id="the-split-we-did-make">The split we did make</h2>
<p>There are two processes, and the axis they split on is more interesting than the monolith decision.</p>
<p>The API server runs HTTP. The worker is a headless context that processes queues: email, notifications, exports, bulk master uploads, bank verification callbacks. Same codebase, different entry point, and the worker deliberately doesn't load the HTTP modules at all.</p>
<p>Neither of those is a service in the domain sense. They're the same code deployed twice, doing work with different operational characteristics. A bulk upload chews CPU and memory in bursts and has no business competing with a dashboard request. Report generation is a different workload from an API call. None of that requires a network boundary between domains; it requires the expensive work to run somewhere else.</p>
<p>That's the axis I'd argue for generally. Split on operational profile — memory, latency tolerance, failure isolation, scaling shape — rather than on domain nouns. Domain nouns give you a diagram that matches the org chart and a distributed transaction where the business logic used to be.</p>
<p>The cost of the two-process model is a real papercut: a new queue needs providers registered in both the API module tree and the worker's, and forgetting one produces a job that enqueues fine and is never consumed. It's written down because it has caught people, including me.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>I don't want to present this as free.</p>
<p>One deploy means one blast radius. A bad release affects everything, so the release process carries weight a small team would rather not carry, and feature flags do a lot of work.</p>
<p>The test suite grows into a wall. It's already the slowest part of the development loop and it will get worse, and running the full thing before every commit stops being viable well before anyone admits it.</p>
<p>No independent scaling within a process. Reporting is enormously more expensive than the CRM, and in one runtime they share resources whether that's sensible or not. This is the cost I feel most often.</p>
<p>And the boundaries are voluntary in a way service boundaries aren't. Nothing physically prevents someone from writing a cross-module query. In a distributed system that mistake is impossible; here it's merely discouraged, and discipline is a weaker guarantee than a network. The mitigation is to put the important invariants somewhere that isn't discipline — the database, a key builder, a test that fails the build — and to accept that the rest rests on review.</p>
<h2 id="what-would-change-my-mind">What would change my mind</h2>
<p>I'd rather name the conditions than defend the position indefinitely.</p>
<p>If a module needs to scale by an order of magnitude beyond the rest and can't share a runtime, it goes. Analytics and reporting are the likeliest candidates as data volumes grow.</p>
<p>If a module ends up owned by a separate team with a genuinely different release cadence, it goes, because at that point the coordination cost is real rather than hypothetical.</p>
<p>If a module acquires materially different compliance requirements — different data residency, a different audit boundary — it goes, and it goes early, because retrofitting that is worse than anything else on this list.</p>
<p>And if the build and test loop crosses the threshold where people start avoiding it, that's a signal on its own, independent of any architectural argument.</p>
<p>None of those has happened yet. When one does, the module boundaries are already drawn and the extraction is a week of work rather than a rewrite. Drawing them properly while everything still lives in one process was the entire point.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[Two bugs from a school ERP, and where validation actually belongs]]></title>
            <link>https://ravipandey.com/articles/validation-at-3500-schools</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/validation-at-3500-schools</guid>
            <pubDate>Sat, 08 Mar 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Three and a half thousand schools sounds like a load problem. It mostly isn't. Schools use an ERP in a pattern so lumpy that averages are useless: admissions land in a six-week window, fee collection spikes on the same three days of the month across the entire customer base, and report cards all get generated in the week before results.</p>
<p>What that concentration really does is statistical. A bug with a one-in-ten-thousand trigger is invisible in testing and happens forty times in a fortnight when the whole country is doing admissions at once. Two of those taught me more about where to put validation than any amount of reading had.</p>
<h2 id="the-child-who-was-admitted-twice">The child who was admitted twice</h2>
<p>A parent fills in an online admission form on a phone, on a patchy connection, and taps submit. Nothing appears to happen. They tap it again.</p>
<p>Two admission records, two admission numbers, two fee ledgers, one child. The school notices in about three weeks when the fee reminders go out in duplicate and an angry parent calls the office.</p>
<p>Everything about this is embarrassing in hindsight, but the interesting part is how many layers we'd thought were handling it.</p>
<p>The form disabled the submit button on click. That does nothing when the first request times out at the network layer and the page never transitions, which is exactly the situation a patchy connection produces. Client-side guards protect against impatience. They don't protect against uncertainty, and a parent who doesn't know whether the form went through is being perfectly rational when they resubmit.</p>
<p>The service layer checked for an existing student before inserting. Two requests arriving four hundred milliseconds apart both ran that check, both found nothing, both inserted. A read-then-write in application code is not a constraint, it's an optimistic wish, and under concurrency it fails precisely when the system is busiest.</p>
<p>Which leaves the database, and this is where it got genuinely difficult, because a unique constraint needs a key and there isn't an obvious one. Name and date of birth collide with siblings more often than you'd expect, especially with twins. Parent phone number is shared across siblings by design. Aadhaar isn't always available at admission time and legally can't be mandatory. Every natural key we proposed had a real counterexample somewhere in the customer base.</p>
<p>So we stopped trying to define what makes a student unique and defined what makes a <em>submission</em> unique instead. The form generates an idempotency key when it loads, sends it with the submission, and there's a unique index on it. A resubmission of the same filled form returns the original record rather than creating a second one. The database enforces it, so no amount of retrying, load balancing or double-tapping gets around it.</p>
<p>Then, separately, a soft duplicate detector: on insert, look for existing students with similar name, same date of birth or same guardian phone, and if any turn up, flag the record for the admissions office rather than blocking it. A human decides. That distinction between a hard constraint the system enforces and a soft signal a person acts on is the thing I took away from all of it.</p>
<h2 id="the-payroll-calculation-that-lived-in-three-places">The payroll calculation that lived in three places</h2>
<p>The second one is less dramatic and cost far more time.</p>
<p>A teacher joins on the 18th of the month. Payroll needs to pay them a part month. Simple enough, and the school's accountant found that our salary slip, our payroll register and the preview shown in the HR portal gave three different figures for the same person.</p>
<p>Not wildly different. Tens of rupees. Which is worse than wildly different, because a large discrepancy gets reported immediately and a small one gets quietly corrected by hand every month until someone mentions it in passing.</p>
<p>The cause was that the same rule had been implemented three times. The frontend had a pro-rata preview so HR could see the effect before saving. The payroll service had the real calculation. The reporting module, which ran against a different data shape, had its own. All three were written from the same specification by three people, and they diverged on the questions the specification hadn't answered: whether the joining day itself is paid, whether you divide by calendar days or working days, and what a working day is when a school's holiday calendar is configurable per branch.</p>
<p>None of those questions had a right answer. They just needed one answer.</p>
<p>The fix was to delete two of the implementations. One function, in one place, that takes the employee, the period and the school's calendar and returns a breakdown. The frontend preview calls the same endpoint that payroll runs. The report reads what payroll stored rather than recomputing it.</p>
<p>Recomputation is the specific smell. If a number can be derived in more than one place in your codebase, it will eventually be derived differently, and the divergence will surface at the worst possible time to somebody who trusts you with their salary. Store the computed result with the inputs and the version of the rule that produced it, and let everything downstream read it.</p>
<p>That last part mattered when the labour rules changed and the calculation had to change with them. Old payslips still show what they showed. Recomputing history because the rule changed is its own category of disaster.</p>
<h2 id="where-each-layer-belongs">Where each layer belongs</h2>
<p>Which brings me to the actual thesis, arrived at the hard way.</p>
<p>The database holds invariants: things that must never be true regardless of which code path ran, who was logged in, or what the client sent. Uniqueness, foreign keys, non-negative amounts, dates that must precede other dates. If your answer to "how do we prevent this" is a code review habit, it belongs here instead.</p>
<p>The service layer holds rules that need context the database doesn't have. Whether this user can admit a student to this branch. Whether the fee structure applies to this class this year. These are rules, and rules have exceptions, so they live somewhere a human can read them and an exception can be recorded.</p>
<p>The form exists to help the person filling it in. It is not a security boundary and it is not a source of truth. It should catch the typo before submission and say something clear, and nothing downstream should assume it ran.</p>
<h2 id="the-rule-nobody-follows">The rule nobody follows</h2>
<p>There's a coda to all this that took me longer to accept.</p>
<p>A school will want to admit a student without a date of birth, because the birth certificate is coming next week and admissions close on Friday. Make the field mandatory with no path around it, and you will not get correct data. You will get 01/01/1900, entered by an office administrator who has a queue of parents in front of her and a deadline that is more real than your validation rule.</p>
<p>We found thousands of those. Every one was a place where our schema said we had a date of birth and we didn't, which is strictly worse than a null, because a null is honest.</p>
<p>So the incomplete admission became a first-class state. Records can be saved with required fields missing, they're marked incomplete, they appear in a list the office works through, and certain operations stay blocked until they're resolved. The validation didn't get weaker. It moved from the moment of entry to the moment it actually mattered, and the data got more truthful as a result.</p>
<p>You can enforce a rule, or you can find out what people are actually doing. Enforcing a rule that fights the workflow gets you neither.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[Ten thousand registrations and the number I was avoiding]]></title>
            <link>https://ravipandey.com/articles/what-analytics-changed-about-egxam</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/what-analytics-changed-about-egxam</guid>
            <pubDate>Sat, 09 Nov 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Ten thousand registrations is a good number to say out loud. I said it a lot.</p>
<p>The number I wasn't saying was how many of those people opened the app in their second week. I knew roughly what it was, in the way you know a thing without looking at it directly, and I put off building the query for longer than I should have.</p>
<p>When I finally built it, it was bad. Not catastrophically bad for an education product, but bad enough that every plan I had for the next quarter was answering the wrong question. We were spending on acquisition to fill a bucket with a hole in it.</p>
<h2 id="retention-in-exam-prep-isnt-retention">Retention in exam prep isn't retention</h2>
<p>The first thing that went wrong was borrowing metrics from products that aren't like ours.</p>
<p>D7 and D30 are the standard, and for an exam-prep app they're close to meaningless. A student preparing for CSIR NET has a date. Everything about their behaviour bends around it. Usage climbs for four months and falls off a cliff the day after the exam, and that cliff is not churn, it's success. Meanwhile a student who registers in March for a December exam and doesn't come back until August hasn't lapsed. They were doing coursework.</p>
<p>So a flat retention curve was the wrong target. What we ended up watching instead was whether a student's activity was tracking their own exam date. Two students with identical D30 could be in completely different situations, and only one of them needed us to do something.</p>
<p>That reframing took an embarrassingly long time, and it came from talking to students rather than from any dashboard. The dashboards had been quietly telling me a story about a leaky consumer app, and we weren't one.</p>
<h2 id="the-funnel-that-mattered">The funnel that mattered</h2>
<p>Once we stopped looking at sessions and started looking at attempts, things got clearer.</p>
<p>The single strongest predictor of whether somebody was still with us three months later was whether they had completed a full-length mock test in their first fortnight. Not watched lectures. Not downloaded notes. Completed a test, seen a score, and looked at the solutions.</p>
<p>I want to be careful here, because this is the point where people usually announce they've found the growth lever and start forcing everyone through it. Correlation was doing a lot of work in that finding. Students who sit a three-hour mock in week two are, on average, more serious to begin with. Making a casual student take a test earlier doesn't turn them into a serious one.</p>
<p>What we could do was remove the friction between a serious student and their first test. Which turned out to be mostly boring: the test list defaulted to full syllabus tests they weren't ready for, so the sensible ones bounced off. Adding shorter chapter tests at the top of that list moved the number more than any feature we shipped that year.</p>
<h2 id="what-actually-moved-the-needle">What actually moved the needle</h2>
<p>The best retention work we did wasn't a feature at all. It was a schedule.</p>
<p>We started running a weekly mock at a fixed time on Sunday morning, with results and an all-India rank published on Sunday evening. Same time every week. Nothing about it was technically interesting. Attendance built over about two months and then stayed, and the students who joined that rhythm behaved completely differently from the ones using the app whenever they felt like it.</p>
<p>A deadline you share with other people is a stronger product than most of the things we'd been building. That has been the most transferable lesson from Egxam into everything else I've worked on, and it isn't a software lesson.</p>
<p>The other lever was the teachers. Ten-odd people writing content, and the difference between an engaged one and a coasting one showed up in the numbers within a month. Not through anything sophisticated, just completion rates on their material and what students wrote in feedback. That was uncomfortable to look at as a manager, because it makes a conversation unavoidable that you'd otherwise let slide for a year.</p>
<h2 id="the-finding-i-didnt-like">The finding I didn't like</h2>
<p>Our most carefully made content was not our most used content.</p>
<p>We had a set of concept videos, properly scripted, genuinely good, that took weeks to produce. They sat there. Meanwhile a set of quickly-made PDFs of previous years' questions with worked solutions got opened constantly, including by students who never touched anything else.</p>
<p>My first reaction was that students were optimising badly and needed guidance. My second, better, reaction was that they knew exactly what they were doing. Three months before an exam, a worked solution to a question that has actually been asked is more valuable than a beautiful explanation of a concept. We were producing what we were proud of rather than what was needed at that point in the cycle.</p>
<p>We didn't stop making the videos. We stopped making them in October.</p>
<h2 id="the-reviews-said-something-different-from-the-ratings">The reviews said something different from the ratings</h2>
<p>We sit at 4.4, which is fine and which tells you nothing. The written reviews were far more useful, and about a third of them were about a single thing: the app's behaviour on poor connections. Not content, not pricing, not features. Whether a downloaded test would survive a train journey.</p>
<p>None of that was visible in any product metric we had, because the students it affected most were the ones least likely to complete the flows we were measuring. Failure is silent in your analytics almost by definition.</p>
<p>I now read the one-star reviews first, and I've kept that habit at work. Support tickets and reviews are the only channel where people tell you about the thing your instrumentation couldn't see.</p>
<p>The second-week number, for what it's worth, roughly doubled over the following year. Most of that came from the Sunday mocks and the chapter tests, which between them cost less engineering time than one of the concept video series did.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[Most of your agent should be a function]]></title>
            <link>https://ravipandey.com/articles/when-a-graph-beats-a-chain</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/when-a-graph-beats-a-chain</guid>
            <pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Most of what gets built as an agent graph should be a function with three calls in it.</p>
<p>I say this as someone who uses LangGraph and would pick it again for the thing I picked it for. But I've also added it to a pipeline, lived with it for a month, and taken it back out, and that removal taught me more about when it's worth having than the successful adoption did.</p>
<h2 id="the-one-i-removed">The one I removed</h2>
<p>Interview Pro's assessment pipeline goes: transcript in, extract findings per rubric criterion, score, assemble a report. I built it as a graph because the work was LLM-shaped and that's what you reach for.</p>
<p>It was a straight line. Every run visited every node in the same order. There was no branching, no state that a later node needed from anywhere except the node before it, and no failure I wanted to recover from in the middle. If scoring fell over, the right outcome was for the whole assessment to fail and get retried by the queue.</p>
<p>What the graph gave me was a state object I had to thread through everything, a control flow I couldn't read top to bottom, and stack traces that went through the framework instead of my code. What it gave me in return was nothing at all, because I wasn't using a single one of its capabilities.</p>
<p>Replacing it with an async function that awaits three things took an afternoon and deleted about two hundred lines. The per-criterion extraction is a <code>gather</code> over a list. That's it. That's the whole orchestration layer.</p>
<h2 id="the-four-things-a-graph-is-actually-for">The four things a graph is actually for</h2>
<p>Having done it both ways, here's what I think you're buying.</p>
<p><strong>Partial failure that doesn't kill the run.</strong> This is the big one and it's why the curriculum generator is a graph. A learning path is around forty lessons generated independently. One of them failing to find grounding is a lesson marked uncovered, not a failed request. A straight-line implementation makes that awkward, because now every step needs its own error handling and the "keep going" logic ends up smeared across the pipeline.</p>
<p><strong>Genuine data-dependent branching.</strong> Not an <code>if</code> you could write in Python, but a route the model chooses, or a loop that runs an unknown number of times until a condition holds. If you find yourself writing a while loop around a set of LLM calls with a step counter and a bail-out, you've started implementing a graph by hand.</p>
<p><strong>Interruption and resume.</strong> Anything with a human in the loop, or anything long enough that a process restart in the middle is unacceptable. Persisting a graph's state and picking it up later is a solved problem in the framework and a genuinely annoying one to build yourself.</p>
<p><strong>Inspecting state per step.</strong> Being able to see exactly what each node received and returned is worth real money when you're debugging why a generated lesson is bad, because the failure is usually three steps upstream of where you noticed it. You can build this with logging. The framework gives it to you consistently, which matters more than it sounds when there are five people on the codebase.</p>
<p>Notice that only one of those four is about orchestration. It's mostly about failure and visibility.</p>
<h2 id="the-test">The test</h2>
<p>Before adding a graph, I try to draw the thing on paper.</p>
<p>If it's a straight line, write a straight line. A function that awaits four calls in order is readable by anyone, typed by your language, and debuggable with a breakpoint. Wrapping it in a state machine to look sophisticated is a cost you pay every time somebody new reads the file.</p>
<p>If the drawing has a branch the model chooses, a loop with no fixed count, or a fan-out where some branches are allowed to fail, use the graph. You'll be building those semantics yourself otherwise, and worse.</p>
<p>The one that's genuinely borderline is fan-out with uniform handling, where every branch does the same thing and any failure fails everything. That's a <code>gather</code> with a semaphore. I've seen it built as a graph several times, including by me.</p>
<h2 id="the-state-object-problem">The state object problem</h2>
<p>The failure mode of graphs that nobody warns you about: the state object becomes a god object.</p>
<p>Every node needs something slightly different, and the path of least resistance is to add a field. Six months in, the state has thirty fields, most nodes read three of them, and no one can tell you which node populates which field without reading all of them. The type signature says every node takes and returns the whole state, so the compiler helps you with nothing.</p>
<p>We handle it by giving each node an explicit input model and an explicit output model, with the graph state as a container rather than the interface nodes are written against. It's more code. It's the difference between a pipeline you can modify in year two and one you rewrite.</p>
<p>None of this is an argument against the framework. It's an argument for reaching for it on the second version, once you know where the branching actually is, rather than on the first, when you're guessing. The straight-line version is cheap to write and cheap to throw away, and it tells you which of the four things you actually need.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
        <item>
            <title><![CDATA[Where tenancy lives]]></title>
            <link>https://ravipandey.com/articles/where-tenancy-lives</link>
            <guid isPermaLink="false">https://ravipandey.com/articles/where-tenancy-lives</guid>
            <pubDate>Wed, 22 Apr 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>The first real incident on our coworking platform wasn't a breach. It was a demo.</p>
<p>We were walking a prospective operator through their dashboard and the occupancy chart for their one centre included desks that belonged to somebody else. Aggregate numbers only, no names, nothing you could call a leak with a straight face. The client didn't notice. I did, and I spent the rest of that call half-listening.</p>
<p>The cause was boring. Every table had a <code>tenant_id</code>, every query was supposed to filter on it, and the endpoint behind that chart ran a grouped aggregate over a date range and didn't.</p>
<p>You will write a few thousand queries over the life of a product. If correctness depends on remembering something a few thousand times, you are not going to remember.</p>
<h2 id="three-options-two-of-them-ruled-out-quickly">Three options, two of them ruled out quickly</h2>
<p>Tenancy can live in the application, as a <code>tenant_id</code> column plus discipline. It can live in the schema, one Postgres schema per tenant. Or it can live in the database, one database or even one cluster each.</p>
<p>Per-database is what you do when a contract contains the words "dedicated infrastructure", or when a regulator does. Our requirement was the opposite. Onboarding a new coworking operator had to be a form somebody in ops fills out, not a ticket for me, and per-database turns customer acquisition into an infrastructure task.</p>
<p>Schema-per-tenant was genuinely tempting. Isolation is real, and per-tenant restore becomes trivial, which sounds unimportant until an operator deletes a workspace on a Friday evening and wants it back on Monday. The cost lands on migrations. Thirty schemas is fine. Three hundred is a twenty-minute deploy that can fail halfway and leave two versions of the truth in one database, and I couldn't convince myself we'd still be at thirty in two years.</p>
<p>So, shared schema. Which means the real decision isn't which of the three you pick. It's whether the isolation is something the database enforces or something the team promises each other.</p>
<h2 id="rls-and-the-pooling-trap">RLS, and the pooling trap</h2>
<div class="group/code relative"><pre class="language-sql"><code class="language-sql"><span class="token keyword">alter</span> <span class="token keyword">table</span> bookings <span class="token keyword">enable</span> <span class="token keyword">row</span> <span class="token keyword">level</span> security<span class="token punctuation">;</span>
<span class="token keyword">alter</span> <span class="token keyword">table</span> bookings <span class="token keyword">force</span> <span class="token keyword">row</span> <span class="token keyword">level</span> security<span class="token punctuation">;</span>

<span class="token keyword">create</span> policy tenant_isolation <span class="token keyword">on</span> bookings
  <span class="token keyword">using</span> <span class="token punctuation">(</span>tenant_id <span class="token operator">=</span> current_setting<span class="token punctuation">(</span><span class="token string">'app.tenant_id'</span><span class="token punctuation">,</span> <span class="token boolean">true</span><span class="token punctuation">)</span>::uuid<span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre><button type="button" aria-label="Copy code" class="absolute top-3 right-3 flex items-center gap-1 rounded-md bg-zinc-800/80 px-2 py-1 text-xs font-medium text-zinc-300 opacity-0 transition group-hover/code:opacity-100 hover:bg-zinc-700 hover:text-zinc-100 focus-visible:opacity-100 max-sm:opacity-100 dark:bg-zinc-700/60 dark:hover:bg-zinc-700"><svg viewBox="0 0 16 16" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="h-3.5 w-3.5 stroke-current"><path d="M5.75 4.75h5.5v6.5h-5.5v-6.5Z"></path><path d="M10.25 4.75V3.25h-6.5v6.5h1.5"></path></svg>Copy</button></div>
<p><code>force</code> matters. Without it the table owner, which is usually the exact role your app connects as, bypasses its own policies, and you get a green test suite that proves nothing.</p>
<p>Every request then sets the variable inside its transaction:</p>
<div class="group/code relative"><pre class="language-ts"><code class="language-ts"><span class="token keyword module">export</span> <span class="token keyword">async</span> <span class="token keyword">function</span> <span class="token generic-function"><span class="token function">withTenant</span><span class="token generic class-name"><span class="token operator">&lt;</span><span class="token constant">T</span><span class="token operator">&gt;</span></span></span><span class="token punctuation">(</span>
  tenantId<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">,</span>
  <span class="token function-variable function">fn</span><span class="token operator">:</span> <span class="token punctuation">(</span>tx<span class="token operator">:</span> <span class="token maybe-class-name">Tx</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token known-class-name class-name">Promise</span><span class="token operator">&lt;</span><span class="token constant">T</span><span class="token operator">&gt;</span><span class="token punctuation">,</span>
<span class="token punctuation">)</span><span class="token operator">:</span> <span class="token known-class-name class-name">Promise</span><span class="token operator">&lt;</span><span class="token constant">T</span><span class="token operator">&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> db<span class="token punctuation">.</span><span class="token method function property-access">$transaction</span><span class="token punctuation">(</span><span class="token keyword">async</span> <span class="token punctuation">(</span>tx<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
    <span class="token keyword control-flow">await</span> tx<span class="token punctuation">.</span><span class="token property-access">$executeRaw</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">select set_config('app.tenant_id', </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>tenantId<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">, true)</span><span class="token template-punctuation string">`</span></span>
    <span class="token keyword control-flow">return</span> <span class="token function">fn</span><span class="token punctuation">(</span>tx<span class="token punctuation">)</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>
</code></pre><button type="button" aria-label="Copy code" class="absolute top-3 right-3 flex items-center gap-1 rounded-md bg-zinc-800/80 px-2 py-1 text-xs font-medium text-zinc-300 opacity-0 transition group-hover/code:opacity-100 hover:bg-zinc-700 hover:text-zinc-100 focus-visible:opacity-100 max-sm:opacity-100 dark:bg-zinc-700/60 dark:hover:bg-zinc-700"><svg viewBox="0 0 16 16" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="h-3.5 w-3.5 stroke-current"><path d="M5.75 4.75h5.5v6.5h-5.5v-6.5Z"></path><path d="M10.25 4.75V3.25h-6.5v6.5h1.5"></path></svg>Copy</button></div>
<p>That third argument is the local flag. The setting is scoped to the transaction and gone on commit, which is load-bearing if you run PgBouncer in transaction pooling mode. A plain <code>SET app.tenant_id</code> outlives the transaction, goes back into the pool along with the connection, and the next request inherits somebody else's tenant.</p>
<p>I've seen this filed as an RLS gotcha. It's connection pooling doing exactly what it advertises, and it will bite any per-session state you set.</p>
<p>Both failure modes are easier to believe when you can switch them on:</p>
<div class="not-prose rounded-2xl border border-zinc-200 bg-white p-5 font-sans sm:p-6 dark:border-zinc-700/50 dark:bg-zinc-900/40"><div class="flex flex-wrap items-baseline justify-between gap-2"><h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">The bookings query</h3><p class="font-mono text-xs text-zinc-500 dark:text-zinc-400">signed in as<!-- --> <span class="text-zinc-800 dark:text-zinc-100">acme</span><span class="mx-1.5">·</span>previous request on this connection:<!-- --> <span class="text-zinc-800 dark:text-zinc-100">globex</span></p></div><div class="mt-5 grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]"><div class="-mx-3"><label class="flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2 transition hover:bg-zinc-100 dark:hover:bg-zinc-800/60"><input type="checkbox" class="mt-0.5 h-4 w-4 flex-none accent-[var(--color-accent-600)] dark:accent-[var(--color-accent-400)]"><span class="flex flex-col gap-0.5"><span class="font-mono text-xs text-zinc-800 dark:text-zinc-100">where tenant_id = $1</span><span class="text-xs text-zinc-500 dark:text-zinc-400">Application-level filter, applied by the ORM scope.</span></span></label><label class="flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2 transition hover:bg-zinc-100 dark:hover:bg-zinc-800/60"><input type="checkbox" class="mt-0.5 h-4 w-4 flex-none accent-[var(--color-accent-600)] dark:accent-[var(--color-accent-400)]"><span class="flex flex-col gap-0.5"><span class="font-mono text-xs text-zinc-800 dark:text-zinc-100">enable row level security</span><span class="text-xs text-zinc-500 dark:text-zinc-400">A policy on the table, scoped to the current tenant setting.</span></span></label><label class="flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2 transition cursor-not-allowed opacity-40"><input type="checkbox" disabled="" class="mt-0.5 h-4 w-4 flex-none accent-[var(--color-accent-600)] dark:accent-[var(--color-accent-400)]"><span class="flex flex-col gap-0.5"><span class="font-mono text-xs text-zinc-800 dark:text-zinc-100">force row level security</span><span class="text-xs text-zinc-500 dark:text-zinc-400">Applies the policy to the table owner too — usually your app’s role.</span></span></label><label class="flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2 transition hover:bg-zinc-100 dark:hover:bg-zinc-800/60"><input type="checkbox" class="mt-0.5 h-4 w-4 flex-none accent-[var(--color-accent-600)] dark:accent-[var(--color-accent-400)]" checked=""><span class="flex flex-col gap-0.5"><span class="font-mono text-xs text-zinc-800 dark:text-zinc-100">PgBouncer transaction pooling</span><span class="text-xs text-zinc-500 dark:text-zinc-400">Connections are handed back to the pool between transactions.</span></span></label><label class="flex cursor-pointer items-start gap-3 rounded-lg px-3 py-2 transition hover:bg-zinc-100 dark:hover:bg-zinc-800/60"><input type="checkbox" class="mt-0.5 h-4 w-4 flex-none accent-[var(--color-accent-600)] dark:accent-[var(--color-accent-400)]"><span class="flex flex-col gap-0.5"><span class="font-mono text-xs text-zinc-800 dark:text-zinc-100">set_config('app.tenant_id', $1, true)</span><span class="text-xs text-zinc-500 dark:text-zinc-400">The third argument scopes the setting to the transaction.</span></span></label></div><div><div class="overflow-x-auto"><table class="w-full text-left font-mono text-xs"><caption class="sr-only">Rows returned by the bookings query under the current configuration</caption><thead class="text-zinc-500 dark:text-zinc-400"><tr><th scope="col" class="py-1.5 pr-3 font-normal">id</th><th scope="col" class="py-1.5 pr-3 font-normal">tenant</th><th scope="col" class="py-1.5 pr-3 font-normal">desk</th><th scope="col" class="py-1.5 font-normal">member</th></tr></thead><tbody><tr class="border-t border-zinc-100 text-zinc-700 dark:border-zinc-800 dark:text-zinc-300"><td class="py-1.5 pr-3">1</td><td class="py-1.5 pr-3">acme</td><td class="py-1.5 pr-3">A-12</td><td class="py-1.5">Priya S.</td></tr><tr class="border-t border-rose-500/20 bg-rose-500/10 text-rose-700 dark:text-rose-300"><td class="py-1.5 pr-3">2</td><td class="py-1.5 pr-3">globex</td><td class="py-1.5 pr-3">B-04</td><td class="py-1.5">Sam O.<span class="ml-2 font-sans text-xs">not yours</span></td></tr><tr class="border-t border-zinc-100 text-zinc-700 dark:border-zinc-800 dark:text-zinc-300"><td class="py-1.5 pr-3">3</td><td class="py-1.5 pr-3">acme</td><td class="py-1.5 pr-3">A-13</td><td class="py-1.5">Dev R.</td></tr><tr class="border-t border-rose-500/20 bg-rose-500/10 text-rose-700 dark:text-rose-300"><td class="py-1.5 pr-3">4</td><td class="py-1.5 pr-3">globex</td><td class="py-1.5 pr-3">B-07</td><td class="py-1.5">Lena M.<span class="ml-2 font-sans text-xs">not yours</span></td></tr><tr class="border-t border-zinc-100 text-zinc-700 dark:border-zinc-800 dark:text-zinc-300"><td class="py-1.5 pr-3">5</td><td class="py-1.5 pr-3">acme</td><td class="py-1.5 pr-3">A-20</td><td class="py-1.5">Arjun K.</td></tr></tbody></table></div><p class="mt-3 font-mono text-xs text-zinc-500 dark:text-zinc-400">5<!-- --> row<!-- -->s</p></div></div><div aria-live="polite" class="mt-5 rounded-xl border p-4 border-rose-500/30 bg-rose-500/5 text-rose-700 dark:text-rose-300"><p class="text-sm font-semibold">Nothing is filtering</p><p class="mt-1.5 text-sm text-zinc-600 dark:text-zinc-300">This is the grouped aggregate that shipped: the right chart, over the wrong rows. No error, no failed test — just somebody else’s desks in your occupancy numbers.</p></div></div>
<p>We kept the application-level filter as well. Both layers, which is unfashionable, and I'd still argue for it: the ORM scope catches the mistake in review with a readable error, RLS catches it in production with an empty result set, and the two fail for unrelated reasons.</p>
<p>Retrofitting was the expensive part. Auditing every query we already had, finding the three that genuinely needed cross-tenant reads, then working out that one of them didn't — it was a report somebody had written against the whole table because that was quicker than the join. Turning RLS on in week one costs an afternoon. Month five cost me a fortnight, spread thin enough that I didn't notice it going.</p>
<h2 id="where-it-doesnt-reach">Where it doesn't reach</h2>
<p>Workers have no request, so they have no tenant. Every job payload carries a tenant id and every handler opens with <code>withTenant</code>. The rule we settled on is that a job touching more than one tenant isn't one job, it's a fan-out that enqueues one per tenant. Retries got a lot less frightening after that.</p>
<p>Redis has never heard of your policies. Every key is namespaced with the tenant id, and I still went looking for exceptions twice a year for a while. The one that got us was a memoised list of amenity types, cached without a prefix on the reasoning that amenities are basically the same everywhere. They were not.</p>
<p>Object storage is the same story: paths prefixed by tenant, signed URLs generated only inside a tenant-scoped call. If you push documents to a search index, the filter has to exist in that engine's query language too, which is where a sensible-looking "search everything" admin feature quietly becomes a normal feature.</p>
<p>Migrations and the support console run as a role that bypasses policies by design. Separate role, separate policy, audit row on every cross-tenant read. If your support tool is just the app with a flag flipped, you don't have isolation, you have a habit.</p>
<h2 id="branding-is-data">Branding is data</h2>
<p>The other half of the requirement was that adding an operator shouldn't involve me. Coworking chains have brands: their logo, their colours, their domain, their booking rules. A centre in Dubai runs a different week from one in Gurugram, because Friday means something different there.</p>
<p>So everything that varies is a row. Theme tokens, locale, working days, cancellation windows, tax config, which modules are switched on. The frontend reads the tenant config in the root layout and hands CSS variables down, and there is no <code>if (tenant === 'ia-spaces')</code> anywhere in the codebase, a rule I enforced in review with more conviction than I could always justify in the moment.</p>
<p>The pressure to break it never stops. Someone always has one customer with one requirement that would be four lines as a special case, and those four lines are how products end up with a <code>customers.ts</code> that nobody can delete. When a genuine one-off turns up, and they do, it goes behind a flag on the tenant record, so at least ops can see it and switch it off.</p>
<p>Custom domains were the fiddliest bit. Resolving host to tenant on every request puts a lookup on the hot path for literally everything, cached with explicit invalidation when the tenant record changes. That cache is the piece of infrastructure I've thought about most since.</p>
<p>The demo went well, incidentally. The operator signed. Their occupancy chart shows their desks.</p>]]></content:encoded>
            <author>ravipandeydu@gmail.com (Ravi Pandey)</author>
        </item>
    </channel>
</rss>