Files
CBS-Mobile-App/docs/one-codebase-many-banks.html
T
Abin ToppoandClaude Opus 5.5 f960a119e8 Add Positive Pay, statement guide v1.0, onboarding, and switch every feature on
- Positive Pay on the 12 Oct /pps contract (account number, not account ID):
  OTP enrollment, cheque submit, saved cheques, detail and stop payment.
  Full flow passed on the phone against mobile-banking-backend.lincpay.
- Statement & Mini Statement guide v1.0 (14 Oct): mini statement JSON on
  Home and the Statement screen; dated PDF export with the password rule.
- Onboarding slides and splash screen, Inter font, shared Icon and
  TypewriterText components, nearby branches via OpenStreetMap.
- Every feature switched on; the 14 Oct switch set is kept in
  featureConfig.stage-2026-10-14.ts. Loans run on demo data until the
  backend has /loans routes.
- npm run android wraps run-android for shells spawned from VS Code.
- OpenProject work packages updated to 14-10-2026 status, with screenshots
  of Positive Pay, statement, loans and cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:18:40 +05:30

1677 lines
130 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>One Codebase, Many Banks</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap">
<style>
/* Layout: a reading column beside a sticky contents rail; each flow pairs a short explanation with a framed, horizontally scrollable diagram. */
:root {
--bg: #F3F5F7;
--surface: #FFFFFF;
--ink: #15202B;
--muted: #556170;
--line: #D7DEE5;
--line-strong: #98A4B0;
--accent: #0B57D0;
--accent-soft: #E4EDFC;
--accent-ink: #0A3F96;
--kill: #B42318;
--kill-soft: #FCEBE9;
--code-bg: #E9EEF3;
--font-display: "IBM Plex Sans Condensed", "Arial Narrow", "Segoe UI", sans-serif;
--font-body: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0E141A; --surface: #151D25; --ink: #E5EAF0; --muted: #9AA7B5;
--line: #25313C; --line-strong: #4C5A68;
--accent: #7FA8F5; --accent-soft: #14284A; --accent-ink: #BCD1FA;
--kill: #F8786D; --kill-soft: #3A1815;
--code-bg: #1C2630;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0E141A; --surface: #151D25; --ink: #E5EAF0; --muted: #9AA7B5;
--line: #25313C; --line-strong: #4C5A68;
--accent: #7FA8F5; --accent-soft: #14284A; --accent-ink: #BCD1FA;
--kill: #F8786D; --kill-soft: #3A1815;
--code-bg: #1C2630;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body {
background: var(--bg);
color: var(--ink);
font: 16px/1.62 var(--font-body);
padding-inline: 20px;
padding-block: 0 72px;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
code { font-family: var(--font-mono); font-size: 0.86em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
pre { font-family: var(--font-mono); font-size: 13px; line-height: 1.6; background: var(--code-bg); padding: 14px 16px; border-radius: 8px; overflow-x: auto; margin: 14px 0 22px; max-width: 760px; }
pre code { background: none; padding: 0; font-size: inherit; }
.wrap { max-width: 1160px; margin: 0 auto; display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 52px; }
@media (max-width: 1020px) { .wrap { grid-template-columns: minmax(0, 1fr); gap: 0; } .toc { display: none; } }
.toc { position: sticky; top: calc(env(safe-area-inset-top, 0px) + 28px); align-self: start; padding-top: 48px; font-size: 14px; }
.toc p { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
.toc a { display: grid; grid-template-columns: 26px 1fr; color: var(--muted); text-decoration: none; padding: 3px 0; line-height: 1.35; }
.toc a span { font-family: var(--font-mono); font-size: 12px; color: var(--line-strong); }
.toc a:hover { color: var(--ink); }
main { min-width: 0; }
header.page { padding-block: 48px 10px; }
.eyebrow { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); margin: 0 0 14px; }
h1 { font-family: var(--font-display); font-weight: 600; font-size: clamp(38px, 5.6vw, 60px); line-height: 1.02; letter-spacing: -0.01em; margin: 0 0 18px; text-wrap: balance; }
.lede { font-size: 19px; line-height: 1.55; max-width: 720px; margin: 0 0 22px; }
.meta { display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 13.5px; color: var(--muted); margin: 0 0 26px; }
.meta b { color: var(--ink); font-weight: 600; }
.toc-inline { display: none; flex-wrap: wrap; gap: 6px 14px; font-size: 14px; margin: 0 0 22px; }
@media (max-width: 1020px) { .toc-inline { display: flex; } }
.map { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; margin: 6px 0 26px; }
@media (max-width: 860px) { .map { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 460px) { .map { grid-template-columns: minmax(0, 1fr); } }
.map a { background: var(--surface); padding: 14px 16px 16px; text-decoration: none; color: var(--ink); min-width: 0; display: block; }
.map a:hover { background: var(--accent-soft); }
.map b { display: block; font-family: var(--font-display); font-weight: 600; font-size: 17px; line-height: 1.25; margin-bottom: 4px; }
.map span { font-size: 13.5px; color: var(--muted); line-height: 1.4; display: block; }
section { padding-block: 40px 8px; border-top: 1px solid var(--line); }
h2 { font-family: var(--font-display); font-weight: 600; font-size: clamp(26px, 3.4vw, 32px); line-height: 1.12; margin: 0 0 12px; text-wrap: balance; display: flex; gap: 12px; align-items: baseline; }
h2 .num { font-family: var(--font-mono); font-weight: 400; font-size: 15px; color: var(--accent); }
h3 { font-size: 18px; font-weight: 600; margin: 30px 0 8px; text-wrap: balance; }
.prose { max-width: 720px; }
.prose p, .prose li { margin: 0 0 10px; }
.prose ul, .prose ol { padding-left: 22px; margin: 0 0 14px; }
figure { margin: 22px 0 28px; }
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 18px; overflow-x: auto; }
figcaption { font-size: 14px; color: var(--muted); margin-top: 10px; max-width: 720px; line-height: 1.5; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 20px; font-size: 13px; color: var(--muted); margin-top: 12px; }
.sw { display: inline-block; width: 13px; height: 13px; border-radius: 3px; border: 1.2px solid var(--line-strong); background: var(--surface); vertical-align: -2px; margin-right: 6px; }
.sw.a { background: var(--accent-soft); border-color: var(--accent); }
.sw.k { background: var(--kill-soft); border-color: var(--kill); }
.sw.n { border-style: dashed; }
.sw.d { border: 0; border-top: 2px dashed var(--muted); border-radius: 0; height: 0; width: 18px; vertical-align: 3px; }
/* Diagrams: page CSS styles the inline SVG through classes, so both themes follow the tokens. */
.dg { display: block; width: 100%; min-width: 760px; height: auto; font-family: var(--font-body); }
.dg text { fill: var(--ink); font-size: 12.5px; }
.dg .tt { font-weight: 600; font-size: 13px; }
.dg .ts { fill: var(--muted); font-size: 11.5px; }
.dg .tm { fill: var(--muted); font-family: var(--font-mono); font-size: 11px; }
.dg .th { fill: var(--muted); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.08em; }
.dg .ta { fill: var(--accent-ink); }
.dg .tk { fill: var(--kill); }
.dg .box { fill: var(--surface); stroke: var(--line-strong); stroke-width: 1; }
.dg .box-a { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 1.2; }
.dg .box-k { fill: var(--kill-soft); stroke: var(--kill); stroke-width: 1.2; }
.dg .grp { fill: none; stroke: var(--line-strong); stroke-width: 1; stroke-dasharray: 4 4; }
.dg .new { stroke-dasharray: 5 3; }
.dg .edge { fill: none; stroke: var(--muted); stroke-width: 1.4; }
.dg .edge-a { fill: none; stroke: var(--accent); stroke-width: 1.4; }
.dg .edge-k { fill: none; stroke: var(--kill); stroke-width: 1.4; }
.dg .ret { fill: none; stroke: var(--muted); stroke-width: 1.4; stroke-dasharray: 5 4; }
.dg .life { stroke: var(--line-strong); stroke-width: 1; stroke-dasharray: 3 4; }
.dg .sep { stroke: var(--line); stroke-width: 1; }
.dg .band { fill: var(--bg); }
.dg .ah { fill: var(--muted); }
.dg .ah-a { fill: var(--accent); }
.dg .ah-k { fill: var(--kill); }
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin: 18px 0 10px; }
@media (max-width: 640px) { .cards { grid-template-columns: minmax(0, 1fr); } }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px 12px; min-width: 0; }
.card h4 { margin: 0 0 2px; font-family: var(--font-display); font-weight: 600; font-size: 19px; }
.card .where { font-family: var(--font-mono); font-size: 12px; color: var(--muted); margin: 0 0 10px; }
.card ul { margin: 0; padding-left: 18px; font-size: 14.5px; }
.card li { margin: 0 0 6px; }
.tbl { overflow-x: auto; margin: 14px 0 22px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
table { border-collapse: collapse; width: 100%; font-size: 14.5px; line-height: 1.45; }
th, td { text-align: left; vertical-align: top; padding: 10px 14px; border-bottom: 1px solid var(--line); }
tr:last-child td { border-bottom: 0; }
th { font-family: var(--font-mono); font-weight: 500; font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
td:first-child { font-weight: 500; }
td.nw { white-space: nowrap; }
td code { white-space: nowrap; }
.pill { display: inline-block; font-family: var(--font-mono); font-size: 11.5px; font-weight: 500; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line-strong); color: var(--muted); white-space: nowrap; }
.pill.a { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); }
.pill.k { border-color: var(--kill); color: var(--kill); background: var(--kill-soft); }
.rule { border-left: 3px solid var(--kill); background: var(--kill-soft); padding: 12px 16px; margin: 18px 0; max-width: 720px; font-size: 15px; }
.rule b { color: var(--kill); }
.note { border-left: 3px solid var(--accent); background: var(--accent-soft); padding: 12px 16px; margin: 18px 0; max-width: 720px; font-size: 15px; }
ol.steps { counter-reset: s; list-style: none; padding: 0; margin: 12px 0 20px; max-width: 720px; }
ol.steps li { counter-increment: s; display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 15px; }
ol.steps li::before { content: counter(s, decimal-leading-zero); font-family: var(--font-mono); font-size: 12.5px; color: var(--accent); padding-top: 2px; }
footer.page { margin-top: 48px; padding-top: 22px; border-top: 1px solid var(--line); font-size: 14px; color: var(--muted); max-width: 720px; }
</style>
<div class="wrap">
<nav class="toc" aria-label="Contents">
<p>Contents</p>
<a href="#summary"><span>00</span>Summary</a>
<a href="#problem"><span>01</span>The problem</a>
<a href="#approach"><span>02</span>Our approach</a>
<a href="#architecture"><span>03</span>Architecture</a>
<a href="#tenant"><span>04</span>Tenant flow</a>
<a href="#features"><span>05</span>Feature flow</a>
<a href="#flows"><span>06</span>Per-bank flows</a>
<a href="#rendering"><span>07</span>How the app renders</a>
<a href="#development"><span>08</span>Development flow</a>
<a href="#deployment"><span>09</span>Deployment flow</a>
<a href="#ota"><span>10</span>OTA flow</a>
<a href="#operations"><span>11</span>Operations flow</a>
<a href="#bank-update"><span>12</span>One bank's update</a>
<a href="#security"><span>13</span>Security</a>
<a href="#risks"><span>14</span>Risks</a>
<a href="#decisions"><span>15</span>Decisions</a>
</nav>
<main>
<header class="page">
<p class="eyebrow">Proposal · Multi-bank mobile banking platform · October 2026</p>
<h1>One Codebase, Many Banks</h1>
<p class="lede">How we build one mobile banking product that ships as a separate app for every bank, with each bank's own features, flows, backend and release schedule, and how we run it safely.</p>
<div class="meta">
<span><b>Status</b> Proposal</span>
<span><b>Audience</b> product, engineering, operations, bank partners</span>
<span><b>Covers</b> mobile apps, backend, delivery, operations</span>
</div>
<nav class="toc-inline" aria-label="Contents">
<a href="#summary">Summary</a><a href="#problem">Problem</a><a href="#approach">Approach</a><a href="#architecture">Architecture</a><a href="#tenant">Tenant</a><a href="#features">Features</a><a href="#flows">Flows</a><a href="#rendering">Rendering</a><a href="#development">Development</a><a href="#deployment">Deployment</a><a href="#ota">OTA</a><a href="#operations">Operations</a><a href="#bank-update">One bank</a><a href="#security">Security</a><a href="#risks">Risks</a><a href="#decisions">Decisions</a>
</nav>
<div class="map">
<a href="#tenant"><b>Tenant flow</b><span>How a bank is created, built into an app and checked on the phone</span></a>
<a href="#features"><b>Feature flow</b><span>How a feature goes from idea to one bank's customers</span></a>
<a href="#flows"><b>Per-bank flows</b><span>How one feature works differently for each bank</span></a>
<a href="#development"><b>Development flow</b><span>Branches, automatic checks and rules for developers</span></a>
<a href="#deployment"><b>Deployment flow</b><span>App builds, backend deployments and versions per bank</span></a>
<a href="#ota"><b>OTA flow</b><span>The weekly JS train, per-bank channels and rollback</span></a>
<a href="#operations"><b>Operations flow</b><span>Who changes what, approvals, monitoring, incidents</span></a>
<a href="#bank-update"><b>One bank's update</b><span>Exactly how each kind of change reaches Bank B</span></a>
</div>
</header>
<!-- 00 SUMMARY -->
<section id="summary">
<h2><span class="num">00</span>Summary</h2>
<div class="prose">
<p><b>The problem.</b> Many banks want the same mobile banking product. Each needs its own app in the stores, its own set of features, its own way of doing some things, and its own data kept apart. A separate app per bank would multiply cost and bugs with every new bank.</p>
<p><b>Our approach.</b> One product, configured per bank:</p>
<ul>
<li><b>One app codebase and one backend codebase.</b> A bank is a tenant pack for the app and a separate deployment of the backend.</li>
<li><b>Features decided by the bank and the server,</b> through five layers, down to the individual customer. A bank's "off" is final (<a href="#switching">how to switch a feature</a>).</li>
<li><b>Different flows without different code:</b> settings, server-driven security steps and journey variants, never <code>if (bank)</code>.</li>
<li><b>Five update lanes,</b> from a live switch in minutes to a store release in weeks. Every code change passes the bank's own UAT first.</li>
<li><b>Operations built in:</b> an admin portal with two-person approval, an audit log and monitoring for every bank.</li>
</ul>
<p><b>Decided:</b> one backend codebase, deployed separately for each bank. <b>To decide:</b> eight questions in <a href="#decisions">section 15</a>, starting with where each bank's backend runs.</p>
</div>
<div class="tbl">
<table>
<thead><tr><th>What banks need</th><th>How we deliver it</th></tr></thead>
<tbody>
<tr><td>Many banks, one product</td><td>One app codebase and one backend codebase; each bank is a tenant pack plus a backend deployment</td></tr>
<tr><td>Their own app in the stores</td><td>A build per bank: its own app ID, name, icon, signing key and store account</td></tr>
<tr><td>Different features per bank and per customer</td><td>Five feature layers; the bank and the server decide; a bank's "off" is final</td></tr>
<tr><td>Different flows per bank</td><td>Settings, server-driven security steps and journey variants</td></tr>
<tr><td>Their data kept apart</td><td>A separate backend, database and key pair for every bank</td></tr>
<tr><td>Fast, safe updates</td><td>OTA per bank with UAT sign-off, staged rollout and instant rollback</td></tr>
<tr><td>Control and accountability</td><td>Admin portal with two-person approval, audit log, per-bank monitoring</td></tr>
<tr><td>Low cost per bank</td><td>A new bank in days; one fix reaches every bank</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 01 PROBLEM -->
<section id="problem">
<h2><span class="num">01</span>The problem</h2>
<div class="prose"><p>Each bank is a separate customer with its own brand, products, rules and approval process. Differences appear at three levels:</p></div>
<div class="tbl">
<table>
<thead><tr><th>Level</th><th>Example</th></tr></thead>
<tbody>
<tr><td>Bank</td><td>Bank A offers loans and cards; Bank B offers only payments</td></tr>
<tr><td>Role</td><td>Customers get the banking app; field agents get the agent app</td></tr>
<tr><td>Customer</td><td>A customer whose KYC is pending cannot apply for a loan</td></tr>
</tbody>
</table>
</div>
<div class="prose"><p>Banks also want the same feature to work differently:</p></div>
<div class="tbl">
<table>
<thead><tr><th>Feature</th><th>Bank A</th><th>Bank B</th><th>Bank C</th></tr></thead>
<tbody>
<tr><td>Approve a transfer</td><td>TPIN, then OTP</td><td>OTP only up to ₹5,000; TPIN and OTP above</td><td>Biometric, then TPIN</td></tr>
<tr><td>Register a device</td><td>Mobile, SMS OTP, MPIN</td><td>Mobile, debit card and PIN, OTP, MPIN</td><td>Mobile, account number and date of birth, OTP, MPIN</td></tr>
<tr><td>Add a payee</td><td>Verify the name, then save</td><td>Save, then a 24-hour cooling period</td><td>Save with an OTP</td></tr>
<tr><td>Loan application</td><td>Product, amount, employment, documents, review</td><td>Product, amount, documents, review</td><td>No loans</td></tr>
</tbody>
</table>
</div>
<h3>What the platform must guarantee</h3>
<div class="tbl">
<table>
<thead><tr><th>#</th><th>Requirement</th></tr></thead>
<tbody>
<tr><td>R1</td><td>One codebase and one set of screens for every bank</td></tr>
<tr><td>R2</td><td>Each bank's own branding, app identity and store listing</td></tr>
<tr><td>R3</td><td>Per-bank features, changeable without a store release</td></tr>
<tr><td>R4</td><td>Per-role and per-customer features, decided by the server</td></tr>
<tr><td>R5</td><td>A bank's "off" can never be overridden by a role, a customer or a device</td></tr>
<tr><td>R6</td><td>Correct behaviour offline and before the network answers</td></tr>
<tr><td>R7</td><td>Each bank's data, keys and backend kept apart</td></tr>
<tr><td>R8</td><td>One command releases any bank, and each bank releases on its own approval</td></tr>
<tr><td>R9</td><td>A new bank in days, and the server enforces everything the app hides</td></tr>
</tbody>
</table>
</div>
<div class="rule"><b>What we will not do:</b> a separate codebase per bank, a Git branch per bank, or <code>if (bank === …)</code> checks in shared code. Each of these multiplies cost and bugs with every new bank.</div>
</section>
<!-- 02 APPROACH -->
<section id="approach">
<h2><span class="num">02</span>Our approach</h2>
<div class="prose">
<ul>
<li><b>One product, configured per bank.</b> One app codebase and one backend codebase. A bank is a tenant pack for the app and a separate deployment of the backend.</li>
<li><b>Bank differences are data.</b> They live in tenant packs and server configuration, never in code branches.</li>
<li><b>The server decides access and security.</b> The app follows the server and never widens what it allows.</li>
<li><b>Fail closed.</b> An unknown feature, step or setting means off or stop.</li>
<li><b>Every bank is isolated:</b> its own app build, backend, data, keys, update channels, store listing and approvals.</li>
<li><b>Every change has a lane.</b> We use the fastest lane a change allows, and no code reaches a bank's customers without that bank's sign-off.</li>
</ul>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 420" role="img" aria-label="Platform overview: one app codebase with a shared core and tenant packs feeds a release script that produces a store build and an OTA update per bank; each installed bank app talks only to its own bank's backend deployment; a central control plane manages the backends and receives metrics without customer data.">
<defs>
<marker id="d1-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d1-ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-a" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="grp" x="20" y="130" width="230" height="176" rx="8"/>
<text class="tt" x="36" y="152">One app codebase</text>
<rect class="box" x="36" y="166" width="198" height="52" rx="6"/>
<text class="tt" x="50" y="188">Shared core</text>
<text class="ts" x="50" y="206">screens, flows, engines</text>
<rect class="box-a" x="36" y="232" width="198" height="56" rx="6"/>
<text class="tt ta" x="50" y="255">Tenant packs</text>
<text class="ts" x="50" y="274">one folder per bank</text>
<line class="edge" x1="135" y1="306" x2="135" y2="327" marker-end="url(#d1-m)"/>
<text class="ts" x="143" y="321">merge</text>
<rect class="box" x="36" y="330" width="198" height="70" rx="6"/>
<text class="tt" x="50" y="352">Release script / CI</text>
<text class="ts" x="50" y="370">checks, then builds</text>
<text class="ts" x="50" y="387">every bank in a loop</text>
<path class="edge" d="M234 342 H296 V190 H327" marker-end="url(#d1-m)"/>
<text class="ts" x="302" y="268">native change</text>
<line class="edge" x1="234" y1="372" x2="327" y2="372" marker-end="url(#d1-m)"/>
<text class="ts" x="281" y="390" text-anchor="middle">JS change</text>
<rect class="box-a" x="330" y="160" width="210" height="60" rx="6"/>
<text class="tt ta" x="344" y="185">Store build per bank</text>
<text class="ts" x="344" y="204">in the bank's own store account</text>
<rect class="box-a" x="330" y="342" width="210" height="60" rx="6"/>
<text class="tt ta" x="344" y="367">OTA update per bank</text>
<text class="ts" x="344" y="386">channel per bank: uat, prod</text>
<line class="edge" x1="540" y1="190" x2="617" y2="190" marker-end="url(#d1-m)"/>
<text class="ts" x="579" y="182" text-anchor="middle">installs</text>
<line class="edge" x1="540" y1="372" x2="617" y2="372" marker-end="url(#d1-m)"/>
<text class="ts" x="579" y="364" text-anchor="middle">updates JS</text>
<rect class="grp" x="620" y="150" width="160" height="250" rx="8"/>
<text class="tt" x="636" y="172">Customer phones</text>
<rect class="box-a" x="636" y="186" width="128" height="44" rx="6"/>
<text class="tt ta" x="700" y="213" text-anchor="middle">Bank A app</text>
<rect class="box-a" x="636" y="244" width="128" height="44" rx="6"/>
<text class="tt ta" x="700" y="271" text-anchor="middle">Bank B app</text>
<rect class="box-a" x="636" y="302" width="128" height="44" rx="6"/>
<text class="tt ta" x="700" y="329" text-anchor="middle">Bank C app</text>
<text class="ts" x="636" y="378">one listing per bank</text>
<line class="edge-a" x1="764" y1="208" x2="829" y2="208" marker-end="url(#d1-ma)"/>
<text class="ts" x="796" y="200" text-anchor="middle">HTTPS</text>
<line class="edge-a" x1="764" y1="266" x2="829" y2="266" marker-end="url(#d1-ma)"/>
<line class="edge-a" x1="764" y1="324" x2="829" y2="324" marker-end="url(#d1-ma)"/>
<rect class="grp" x="820" y="150" width="170" height="250" rx="8"/>
<text class="tt" x="832" y="172">Per-bank backends</text>
<rect class="box-a" x="832" y="186" width="146" height="44" rx="6"/>
<text class="tt ta" x="844" y="205">Bank A backend</text>
<text class="ts" x="844" y="221">own data, own CBS</text>
<rect class="box-a" x="832" y="244" width="146" height="44" rx="6"/>
<text class="tt ta" x="844" y="263">Bank B backend</text>
<text class="ts" x="844" y="279">own data, own CBS</text>
<rect class="box-a" x="832" y="302" width="146" height="44" rx="6"/>
<text class="tt ta" x="844" y="321">Bank C backend</text>
<text class="ts" x="844" y="337">own data, own CBS</text>
<text class="ts" x="832" y="368">one backend codebase,</text>
<text class="ts" x="832" y="386">deployed per bank</text>
<rect class="box" x="620" y="20" width="370" height="56" rx="6"/>
<text class="tt" x="636" y="43">Platform control plane (ours)</text>
<text class="ts" x="636" y="62">ops console · catalog · monitoring · no customer data</text>
<line class="edge" x1="905" y1="76" x2="905" y2="147" marker-end="url(#d1-m)"/>
<text class="ts" x="912" y="118">manages</text>
<line class="ret" x1="860" y1="149" x2="860" y2="79" marker-end="url(#d1-m)"/>
<text class="ts" x="853" y="118" text-anchor="end">metrics</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Exists once per bank</span><span><i class="sw"></i>Shared by every bank</span></div>
</div>
<figcaption>Everything blue exists once per bank: the app build, its OTA channel and its backend deployment. Each app talks only to its own bank's backend. The control plane holds no customer data; it manages the backends over an audited link and collects their metrics.</figcaption>
</figure>
<h3>How each kind of difference is handled</h3>
<div class="tbl">
<table>
<thead><tr><th>What differs between banks</th><th>Handled by</th><th>How a change reaches phones</th></tr></thead>
<tbody>
<tr><td>Identity and branding</td><td>Tenant pack, chosen at build time</td><td>OTA for text, colours and images; store build for icon, name and app ID</td></tr>
<tr><td>Which features the bank offers</td><td>Pack defaults and the bank's live switches</td><td>Next config refresh, within minutes</td></tr>
<tr><td>Which features a customer gets</td><td>Server rules applied at sign-in</td><td>Next sign-in or refresh</td></tr>
<tr><td>How a feature works</td><td>Settings, server-driven security steps, journey variants</td><td>Next config refresh, or the next flow started</td></tr>
<tr><td>Where data lives</td><td>The bank's own backend deployment</td><td>Not applicable: it never leaves the bank's backend</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 03 ARCHITECTURE -->
<section id="architecture">
<h2><span class="num">03</span>Architecture</h2>
<h3>Inside the app</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 380" role="img" aria-label="App layers from top to bottom: app shell, navigation, modules, flows, a decision layer of feature engine, bank settings and strategies, the tenant pack, and services. The tenant pack enters at build time; feature config and the flow engine feed the decision and flows layers at runtime.">
<defs>
<marker id="d3-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d3-ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-a" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="220" y="20" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="45">App shell</text>
<text class="ts" x="410" y="45">providers · error boundary · bootstrap · tenant guard</text>
<rect class="box" x="220" y="70" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="95">Navigation</text>
<text class="ts" x="410" y="95">a screen exists only if its feature is on</text>
<rect class="box" x="220" y="120" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="145">Modules</text>
<text class="ts" x="410" y="145">home · payments · services · loans · cards · agent</text>
<rect class="box" x="220" y="170" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="195">Flows</text>
<text class="ts" x="410" y="195">challenge runner · journey runner</text>
<rect class="box" x="220" y="220" width="196" height="40" rx="6"/>
<text class="tt" x="318" y="245" text-anchor="middle">Feature engine</text>
<rect class="box" x="432" y="220" width="196" height="40" rx="6"/>
<text class="tt" x="530" y="245" text-anchor="middle">Bank settings</text>
<rect class="box" x="644" y="220" width="196" height="40" rx="6"/>
<text class="tt" x="742" y="245" text-anchor="middle">Strategies &amp; slots</text>
<rect class="box-a" x="220" y="270" width="620" height="40" rx="6"/>
<text class="tt ta" x="236" y="295">Tenant pack (@tenant)</text>
<text class="ts" x="410" y="295">this bank's identity, backend address and defaults</text>
<rect class="box" x="220" y="320" width="620" height="40" rx="6"/>
<text class="tt" x="236" y="345">Services</text>
<text class="ts" x="410" y="345">API client · security · storage</text>
<rect class="box-a" x="20" y="270" width="150" height="40" rx="6"/>
<text class="tm ta" x="95" y="294" text-anchor="middle">tenants/bank-a</text>
<line class="edge-a" x1="170" y1="290" x2="217" y2="290" marker-end="url(#d3-ma)"/>
<text class="ts" x="194" y="282" text-anchor="middle">build</text>
<rect class="box" x="870" y="170" width="120" height="40" rx="6"/>
<text class="tt" x="930" y="195" text-anchor="middle">Flow engine</text>
<line class="edge" x1="870" y1="190" x2="843" y2="190" marker-end="url(#d3-m)"/>
<rect class="box" x="870" y="220" width="120" height="40" rx="6"/>
<text class="tm" x="930" y="244" text-anchor="middle">/features/config</text>
<line class="edge" x1="870" y1="240" x2="843" y2="240" marker-end="url(#d3-m)"/>
<text class="ts" x="930" y="282" text-anchor="middle">runtime, from the</text>
<text class="ts" x="930" y="298" text-anchor="middle">bank's backend</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Differs per bank</span></div>
</div>
<figcaption>Screens never read the tenant pack or the server directly. They ask the decision layer ("is it on?", "what value?", "which rule?"), so a screen is the same for every bank.</figcaption>
</figure>
<h3>Backend: one codebase, one deployment per bank</h3>
<div class="prose"><p>The backend follows the same rule as the app: one codebase and one versioned build, deployed separately for each bank and each environment. Banks differ only in configuration: their core-banking connection, database, keys, certificates and feature settings.</p></div>
<div class="tbl">
<table>
<thead><tr><th>Component</th><th>Runs</th><th>Job</th></tr></thead>
<tbody>
<tr><td>Mobile and business APIs</td><td>In each bank's backend</td><td>Banking APIs, with an entitlement check on every call (<code>FEATURE_NOT_AVAILABLE</code>)</td></tr>
<tr><td>Config service</td><td>In each bank's backend</td><td>The bank's switches, settings and journey variants, plus per-customer entitlements after sign-in</td></tr>
<tr><td>Flow engine</td><td>In each bank's backend</td><td>The bank's security policy; replies one step at a time (TPIN, OTP, biometric)</td></tr>
<tr><td>Bank admin portal</td><td>In each bank's backend</td><td>Bank staff change their own bank, with two-person approval</td></tr>
<tr><td>Audit log</td><td>In each bank's backend</td><td>Every change and every ops action, kept with the bank's own data</td></tr>
<tr><td>Database, key pair, core-banking link</td><td>In each bank's backend</td><td>The bank's customers and sessions, its PIN encryption key pair, its connection to its core banking system</td></tr>
<tr><td>Ops console</td><td>Central</td><td>Onboarding, security policies, emergency kill, support lookup; reaches each backend over a private, audited link</td></tr>
<tr><td>Feature catalog</td><td>Central</td><td>Every feature key the app knows; each backend imports it when it is deployed</td></tr>
<tr><td>OTA server</td><td>Central</td><td>App bundles and update channels, self-hosted</td></tr>
<tr><td>Monitoring</td><td>Central</td><td>Crash reports, metrics and logs without customer data, tagged by bank and version</td></tr>
</tbody>
</table>
</div>
<h3>Environments</h3>
<div class="tbl">
<table>
<thead><tr><th>Environment</th><th>Used by</th><th>Backend</th><th>App build</th><th>OTA channel</th><th>Test data</th></tr></thead>
<tbody>
<tr><td>Dev</td><td>Developers</td><td>Shared dev backend, or a local mock</td><td><code>bankADevDebug</code></td><td>none</td><td>Allowed, debug builds only</td></tr>
<tr><td>UAT</td><td>Our QA, the bank's testers</td><td>Bank A's UAT backend</td><td><code>bankAUatRelease</code></td><td><code>bank-a-uat</code></td><td>Never mock data</td></tr>
<tr><td>Prod</td><td>Customers</td><td>Bank A's production backend</td><td><code>bankAProdRelease</code></td><td><code>bank-a-prod</code></td><td>Never</td></tr>
</tbody>
</table>
</div>
<div class="note"><b>No hand-edited server addresses.</b> Each bank's backend address and certificate pins, per environment, live in its tenant pack. The build picks them from the bank and the environment.</div>
</section>
<!-- 04 TENANT -->
<section id="tenant">
<h2><span class="num">04</span>Tenant flow</h2>
<div class="prose"><p>A tenant is one bank. It exists in four places, tied together by one id such as <code>bank-b</code>.</p></div>
<div class="cards">
<div class="card">
<h4>Code</h4>
<p class="where">tenants/bank-b/</p>
<ul>
<li>Identity: org id, name, logo, brand colours, wording</li>
<li>Backend address and certificate pins per environment</li>
<li>Feature defaults, settings, flow choices</li>
</ul>
</div>
<div class="card">
<h4>Native shell</h4>
<p class="where">Android flavor bankB · iOS scheme BankB</p>
<ul>
<li>App ID, launcher icon, home-screen name</li>
<li>Upload signing key, kept in CI secrets</li>
<li>OTA channel baked in: <code>bank-b-prod</code></li>
</ul>
</div>
<div class="card">
<h4>Backend</h4>
<p class="where">Bank B's own deployment · UAT and prod</p>
<ul>
<li>Its own database, key pair and core-banking connection</li>
<li>Live switches, settings, security policy, customer rules</li>
<li>Admin portal, approvals, audit log</li>
</ul>
</div>
<div class="card">
<h4>Delivery</h4>
<p class="where">tenants/registry.json · stores · OTA server</p>
<ul>
<li>Store listing in Bank B's own developer account</li>
<li>OTA channels <code>bank-b-uat</code> and <code>bank-b-prod</code></li>
<li>One registry entry: flavor, app IDs, version</li>
</ul>
</div>
</div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 160" role="img" aria-label="How the tenant is chosen and checked: the release script sets TENANT=bank-b; Metro bundles only Bank B's pack; the Gradle flavor gives Bank B's app ID, icon and channel; on the phone the tenant guard compares the bundle's bank with the binary's channel and refuses to start on a mismatch.">
<defs>
<marker id="n2-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="th" x="20" y="30">BUILD TIME</text>
<line class="sep" x1="20" y1="40" x2="585" y2="40"/>
<text class="th" x="605" y="30">ON THE PHONE</text>
<line class="sep" x1="605" y1="40" x2="980" y2="40"/>
<rect class="box" x="20" y="54" width="170" height="86" rx="6"/>
<text class="tt" x="34" y="78">release.js</text>
<text class="tm" x="34" y="99">TENANT=bank-b</text>
<text class="tm" x="34" y="119">APP_ENV=prod</text>
<line class="edge" x1="190" y1="97" x2="213" y2="97" marker-end="url(#n2-m)"/>
<rect class="box" x="215" y="54" width="175" height="86" rx="6"/>
<text class="tt" x="229" y="78">Metro</text>
<text class="tm" x="229" y="99">@tenant → bank-b</text>
<text class="ts" x="229" y="119">only Bank B's pack</text>
<line class="edge" x1="390" y1="97" x2="408" y2="97" marker-end="url(#n2-m)"/>
<rect class="box-a" x="410" y="54" width="175" height="86" rx="6"/>
<text class="tt ta" x="424" y="78">Gradle flavor</text>
<text class="tm" x="424" y="99">bankBProdRelease</text>
<text class="ts" x="424" y="119">app ID, icon, channel</text>
<line class="edge" x1="585" y1="97" x2="603" y2="97" marker-end="url(#n2-m)"/>
<rect class="box-a" x="605" y="54" width="175" height="86" rx="6"/>
<text class="tt ta" x="619" y="78">Bank B's phone</text>
<text class="tm" x="619" y="99">bundle: bank-b</text>
<text class="tm" x="619" y="119">binary: bank-b-prod</text>
<line class="edge" x1="780" y1="97" x2="798" y2="97" marker-end="url(#n2-m)"/>
<rect class="box" x="800" y="54" width="180" height="86" rx="6"/>
<text class="tt" x="814" y="78">Tenant guard</text>
<text class="ta" x="814" y="99">match → app starts</text>
<text class="tk" x="814" y="119">mismatch → refuse</text>
</svg>
</div>
<figcaption>The bank is fixed at build time, so Bank B's app never contains Bank A's logo, config or server address. If a wrong OTA bundle ever lands on a phone, the guard refuses it before the first screen and the OTA client rolls back. The app also checks the org id in the first config reply, so a pack pointing at the wrong bank's backend stops at once.</figcaption>
</figure>
<div class="prose"><p>The pack is a typed file. The build fails if a field is missing.</p></div>
<pre><code>// tenants/bank-b/index.ts
export default {
id: 'bank-b',
orgId: '…', // from onboarding
displayName: 'Bank B Mobile',
api: { // Bank B's own backend, per environment
uat: 'https://uat-api.bank-b.example',
prod: 'https://api.bank-b.example',
},
pins: { uat: ['sha256/…'], prod: ['sha256/…'] }, // TLS certificate pins
features: { loans: false, cards: true },
settings: { payments: { rtgsMinimum: 200000 } },
flows: { transferAuthorisation: 'server-driven' },
brand,
logo: require('./assets/logo.png'),
} satisfies TenantConfig;</code></pre>
<h3>Tenant lifecycle</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 200" role="img" aria-label="Bank lifecycle: agree scope, set up, UAT, go live, operate, change. Every change loops back to UAT.">
<defs>
<marker id="d7-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d7-ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-a" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="20" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="34" y="54">1 · Agree scope</text>
<text class="ts" x="34" y="74">contract, features</text>
<text class="tm" x="34" y="94">bank + us</text>
<line class="edge" x1="160" y1="72" x2="180" y2="72" marker-end="url(#d7-m)"/>
<rect class="box" x="182" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="196" y="54">2 · Set up</text>
<text class="ts" x="196" y="74">pack, build, backend</text>
<text class="tm" x="196" y="94">us + DevOps</text>
<line class="edge" x1="322" y1="72" x2="342" y2="72" marker-end="url(#d7-m)"/>
<rect class="box-a" x="344" y="30" width="140" height="84" rx="6"/>
<text class="tt ta" x="358" y="54">3 · UAT</text>
<text class="ts" x="358" y="74">bank tests UAT</text>
<text class="tm" x="358" y="94">bank</text>
<line class="edge" x1="484" y1="72" x2="504" y2="72" marker-end="url(#d7-m)"/>
<rect class="box" x="506" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="520" y="54">4 · Go live</text>
<text class="ts" x="520" y="74">store + prod OTA</text>
<text class="tm" x="520" y="94">bank + us</text>
<line class="edge" x1="646" y1="72" x2="666" y2="72" marker-end="url(#d7-m)"/>
<rect class="box" x="668" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="682" y="54">5 · Operate</text>
<text class="ts" x="682" y="74">monitor, toggle</text>
<text class="tm" x="682" y="94">bank + us</text>
<line class="edge" x1="808" y1="72" x2="828" y2="72" marker-end="url(#d7-m)"/>
<rect class="box" x="830" y="30" width="140" height="84" rx="6"/>
<text class="tt" x="844" y="54">6 · Change</text>
<text class="ts" x="844" y="74">features, flows</text>
<text class="tm" x="844" y="94">bank + us</text>
<path class="edge-a" d="M900 114 V160 H414 V118" marker-end="url(#d7-ma)"/>
<text class="ts" x="657" y="180" text-anchor="middle">every change goes back through UAT</text>
</svg>
</div>
<figcaption>A bank moves through the same six stages. After go-live, every change, large or small, goes back through that bank's UAT.</figcaption>
</figure>
<h3>Onboarding a bank</h3>
<ol class="steps">
<li>Copy <code>tenants/_template</code> to <code>tenants/bank-c</code>; fill in identity, brand colours, logo, features, settings, backend addresses and certificate pins.</li>
<li>Run the contrast check with Bank C's colours and adjust until every text passes.</li>
<li>Add Bank C to <code>tenants/registry.json</code>; its app builds and CI jobs appear automatically.</li>
<li>Add launcher icons and the iOS configuration for Bank C.</li>
<li>DevOps stores Bank C's signing key in CI secrets.</li>
<li>DevOps creates Bank C's UAT and production backends from the current backend release with the infrastructure scripts: database, key pair, TLS certificate, core-banking connection.</li>
<li>Ops configures both backends: feature switches, customer rules, security policy, admin users.</li>
<li>The bank creates its store listing and gives our team access.</li>
<li>Build to <code>bank-c-uat</code>; the bank tests and signs off; release to production.</li>
</ol>
<div class="prose">
<p><b>Offboarding:</b> switch the bank's features off live, unpublish its store listing with the bank, retire its two OTA channels, hand over or archive its data as the contract requires, shut down its backends, and remove the pack from the registry.</p>
</div>
</section>
<!-- 05 FEATURES -->
<section id="features">
<h2><span class="num">05</span>Feature flow</h2>
<div class="prose"><p>Every screen, tab, button and action has a feature key such as <code>loans.apply</code>. Keys form a tree: <code>loans</code> off hides everything under it. The app resolves each key through five layers, and a later layer overrides an earlier one, with one exception that protects the bank.</p></div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 222" role="img" aria-label="Five feature layers in priority order: product default, bank pack, bank live, session, local device. A false from bank live or session is final; no later layer can switch it back on.">
<defs>
<marker id="d4-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d4-mk" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-k" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="ts" x="20" y="16">Later layers override earlier ones</text>
<line class="edge" x1="20" y1="26" x2="928" y2="26" marker-end="url(#d4-m)"/>
<rect class="box" x="20" y="44" width="170" height="84" rx="6"/>
<text class="tt" x="34" y="68">Product default</text>
<text class="ts" x="34" y="88">set by developers</text>
<text class="ts" x="34" y="106">compiled in</text>
<rect class="box-a" x="205" y="44" width="170" height="84" rx="6"/>
<text class="tt ta" x="219" y="68">Bank pack</text>
<text class="ts" x="219" y="88">set by mobile team</text>
<text class="ts" x="219" y="106">compiled per bank</text>
<rect class="box-k" x="390" y="44" width="170" height="84" rx="6"/>
<text class="tt tk" x="404" y="68">Bank live</text>
<text class="ts" x="404" y="88">set by bank admin</text>
<text class="ts" x="404" y="106">fetched, cached</text>
<rect class="box-k" x="575" y="44" width="170" height="84" rx="6"/>
<text class="tt tk" x="589" y="68">Session</text>
<text class="ts" x="589" y="88">set by server rules</text>
<text class="ts" x="589" y="106">role + this customer</text>
<rect class="box" x="760" y="44" width="170" height="84" rx="6"/>
<text class="tt" x="774" y="68">Local device</text>
<text class="ts" x="774" y="88">set by QA or staff</text>
<text class="ts" x="774" y="106">release: off only</text>
<path class="edge-k" d="M475 128 V150 M660 128 V150 M475 150 H660"/>
<line class="edge-k" x1="567" y1="150" x2="567" y2="164" marker-end="url(#d4-mk)"/>
<rect class="box-k" x="330" y="166" width="475" height="36" rx="6"/>
<text class="tk" x="567" y="189" text-anchor="middle">A false here is final. No layer to its right can switch it back on.</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Differs per bank</span><span><i class="sw k"></i>Its "off" is final (kill switch)</span></div>
</div>
<figcaption>Two more rules: a feature is on only if every parent is on, and an unknown key is always off. The last known bank config is cached, so a kill switch still holds when the phone starts offline.</figcaption>
</figure>
<div class="tbl">
<table>
<thead><tr><th>Feature</th><th>Bank A · customer X (KYC pending)</th><th>Bank A · customer Y</th><th>Bank B · anyone</th></tr></thead>
<tbody>
<tr><td><code>loans</code></td><td><span class="pill a">on</span> bank pack</td><td><span class="pill a">on</span> bank pack</td><td><span class="pill">off</span> bank pack</td></tr>
<tr><td><code>loans.apply</code></td><td><span class="pill k">off</span> session rule</td><td><span class="pill a">on</span></td><td><span class="pill">off</span> parent is off</td></tr>
<tr><td><code>cards</code> (Bank A outage)</td><td><span class="pill k">off</span> bank live</td><td><span class="pill k">off</span> bank live</td><td><span class="pill">off</span> default</td></tr>
<tr><td>Staff phone turns <code>cards</code> on</td><td><span class="pill k">off</span> still killed</td><td><span class="pill k">off</span> still killed</td><td><span class="pill">off</span> release ignores it</td></tr>
</tbody>
</table>
</div>
<h3>A feature's life</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 160" role="img" aria-label="A feature's life in seven steps: decide, build with the key off, ship dark by OTA to every bank, test in one bank's UAT, go live in that bank, narrow with customer rules, retire the flag.">
<defs>
<marker id="n3-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="th" x="20" y="30">PLAN</text>
<line class="sep" x1="20" y1="40" x2="144" y2="40"/>
<text class="th" x="159" y="30">CODE · EVERY BANK</text>
<line class="sep" x1="159" y1="40" x2="422" y2="40"/>
<text class="th" x="437" y="30">SERVER · ONE BANK AT A TIME</text>
<line class="sep" x1="437" y1="40" x2="839" y2="40"/>
<text class="th" x="854" y="30">CODE</text>
<line class="sep" x1="854" y1="40" x2="978" y2="40"/>
<rect class="box" x="20" y="52" width="124" height="84" rx="6"/>
<text class="tt" x="32" y="76">1 · Decide</text>
<text class="ts" x="32" y="97">rung + key name</text>
<text class="tm" x="32" y="117">product, bank</text>
<line class="edge" x1="144" y1="94" x2="157" y2="94" marker-end="url(#n3-m)"/>
<rect class="box" x="159" y="52" width="124" height="84" rx="6"/>
<text class="tt" x="171" y="76">2 · Build</text>
<text class="ts" x="171" y="97">key ships off</text>
<text class="tm" x="171" y="117">developers</text>
<line class="edge" x1="283" y1="94" x2="296" y2="94" marker-end="url(#n3-m)"/>
<rect class="box" x="298" y="52" width="124" height="84" rx="6"/>
<text class="tt" x="310" y="76">3 · Ship dark</text>
<text class="ts" x="310" y="97">OTA, hidden</text>
<text class="tm" x="310" y="117">release mgr</text>
<line class="edge" x1="422" y1="94" x2="435" y2="94" marker-end="url(#n3-m)"/>
<rect class="box-a" x="437" y="52" width="124" height="84" rx="6"/>
<text class="tt ta" x="449" y="76">4 · Test</text>
<text class="ts" x="449" y="97">on in bank UAT</text>
<text class="tm" x="449" y="117">ops, bank QA</text>
<line class="edge" x1="561" y1="94" x2="574" y2="94" marker-end="url(#n3-m)"/>
<rect class="box-a" x="576" y="52" width="124" height="84" rx="6"/>
<text class="tt ta" x="588" y="76">5 · Go live</text>
<text class="ts" x="588" y="97">on in bank prod</text>
<text class="tm" x="588" y="117">bank admin</text>
<line class="edge" x1="700" y1="94" x2="713" y2="94" marker-end="url(#n3-m)"/>
<rect class="box-a" x="715" y="52" width="124" height="84" rx="6"/>
<text class="tt ta" x="727" y="76">6 · Narrow</text>
<text class="ts" x="727" y="97">customer rules</text>
<text class="tm" x="727" y="117">bank decides</text>
<line class="edge" x1="839" y1="94" x2="852" y2="94" marker-end="url(#n3-m)"/>
<rect class="box" x="854" y="52" width="124" height="84" rx="6"/>
<text class="tt" x="866" y="76">7 · Retire</text>
<text class="ts" x="866" y="97">remove leaf flag</text>
<text class="tm" x="866" y="117">developers</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Done on the server, per bank, with no app release</span></div>
</div>
<figcaption>Steps 4 to 6 repeat for each bank that wants the feature, on that bank's schedule. Module roots (such as <code>loans</code>) and money actions keep their switch for ever as kill switches; leaf flags are removed once fully on.</figcaption>
</figure>
<div class="prose">
<ul>
<li><b>The root key decides a module.</b> Children default to on and are switched off only to remove one capability.</li>
<li><b>New keys ship off.</b> A CI check fails any new key that defaults to on without a written reason, so an update never shows a new feature to a bank that did not ask for it.</li>
<li><b>The backend knows every key.</b> CI publishes a feature catalog on each merge; each backend imports it, so the admin portal lists the same keys the app understands.</li>
<li><b>The server checks again.</b> Every endpoint maps to its feature key and refuses with <code>FEATURE_NOT_AVAILABLE</code>.</li>
<li><b>Backend first, then switch on.</b> A feature that needs a newer backend stays off for a bank until that bank's backend is upgraded. Each backend reports what it supports, and its config service never switches on a feature it cannot serve.</li>
</ul>
</div>
<h3 id="switching">How to switch a feature on or off</h3>
<div class="prose"><p>There are five places to switch a feature. Which one to use depends on who decides and for whom. Day to day, the bank admin portal is the normal way; the others are defaults, automatic rules or testing tools.</p></div>
<div class="tbl">
<table>
<thead><tr><th>Who switches it</th><th>Affects</th><th>Where</th><th>Takes effect</th></tr></thead>
<tbody>
<tr><td>Developer</td><td>Every bank: the product default</td><td>The default feature config, in code</td><td>Next app update</td></tr>
<tr><td>Mobile team</td><td>One bank's starting setup</td><td><code>tenants/bank-b/features.ts</code></td><td>Next app update for that bank</td></tr>
<tr><td><b>Bank admin</b></td><td><b>All customers of that bank, live</b></td><td><b>Admin portal, with two-person approval</b></td><td><b>Minutes, no app release</b></td></tr>
<tr><td>Server rules</td><td>One role or one customer</td><td>Rules in the bank's backend, or a manual block with an expiry</td><td>The customer's next sign-in</td></tr>
<tr><td>QA or staff</td><td>One phone</td><td>The feature switchboard screen in the app</td><td>Immediately, on that phone</td></tr>
</tbody>
</table>
</div>
<div class="prose"><p>The bank's backend sends its decisions in one config reply. Bank-wide switches arrive at app start, on return to the app and at sign-in. The customer's own switches arrive after sign-in.</p></div>
<pre><code>{
"version": 44,
"overrides": { "loans": false, "cards": true }, // the whole bank
"roleOverrides": { "payments.beneficiaries.add": false } // this customer, after sign-in
}</code></pre>
<div class="prose">
<p><b>How the app decides</b></p>
<ol>
<li>An unknown key is off.</li>
<li>If the bank or the session says off, it is off. Nothing can undo it.</li>
<li>If a parent is off (for example <code>loans</code> for <code>loans.apply</code>), it is off.</li>
<li>Otherwise, the most specific switch that has a value wins.</li>
</ol>
<p><b>The switchboard (testing only).</b> The switchboard lists every key with its current value and the reason, such as "turned off by the bank" or "parent is off". In UAT builds, testers can switch features on and off. In production builds it can only switch features off, it never undoes a bank's switch, and only staff the bank allows can open it.</p>
<p><b>What the customer sees when a feature turns off</b></p>
<ul>
<li>The tab or menu item disappears. There is no greyed-out button and no "coming soon" message.</li>
<li>A customer already on that screen is taken back. During a payment, the app waits until the payment ends.</li>
<li>Any remaining action is blocked in the app, and the server refuses it with <code>FEATURE_NOT_AVAILABLE</code>.</li>
</ul>
<p><b>Making a new feature switchable</b></p>
</div>
<pre><code>// 1. Add a key; new keys always ship off
LOANS_APPLY = 'loans.apply'
[FeatureKey.LOANS_APPLY]: { enabled: false }
// 2. Gate the screen: the route is not registered while the feature is off
ApplyLoan: FeatureKey.LOANS_APPLY
// 3. Gate the button
&lt;FeatureGate feature={FeatureKey.LOANS_APPLY}&gt;
&lt;Button title="Apply for loan" onPress={apply} /&gt;
&lt;/FeatureGate&gt;
// 4. Guard the action
const apply = useFeatureAction(FeatureKey.LOANS_APPLY, submitApplication);</code></pre>
<div class="prose"><p>On the server, the loan-application endpoint maps to <code>loans.apply</code> and refuses the call while it is off. CI publishes the new key in the feature catalog, so it appears in every bank's admin portal.</p></div>
<div class="tbl">
<table>
<thead><tr><th>You want to…</th><th>Use</th><th>Takes effect</th></tr></thead>
<tbody>
<tr><td>Turn loans off for Bank B during an incident</td><td>Bank B's admin portal</td><td>Minutes</td></tr>
<tr><td>Launch UPI for Bank A only</td><td>The key ships off in an app update; Bank A's admin turns it on</td><td>Minutes after the update reaches phones</td></tr>
<tr><td>Stop one customer adding payees for a day</td><td>A manual block with a 24-hour expiry</td><td>That customer's next sign-in</td></tr>
<tr><td>Test cards on a QA phone</td><td>The switchboard on a UAT build</td><td>Immediately</td></tr>
<tr><td>Remove a feature from one bank for good</td><td>The bank's pack, with the bank's switch off until the update lands</td><td>Next app update</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 06 PER-BANK FLOWS -->
<section id="flows">
<h2><span class="num">06</span>Per-bank flows</h2>
<div class="prose"><p>When a bank wants a feature to work differently, we pick the lowest rung that fits. Each step up adds power and upkeep.</p></div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 318" role="img" aria-label="The variation ladder, five rungs from lowest to highest: setting, server challenge, journey variant, strategy, slot override. The first three can change without an app update; strategies and slot overrides need an OTA update.">
<defs>
<marker id="d5-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="ts" x="20" y="28">Higher rung: more power, more upkeep. Use the lowest rung that fits.</text>
<line class="edge" x1="20" y1="40" x2="975" y2="40" marker-end="url(#d5-m)"/>
<rect class="box-a" x="20" y="200" width="180" height="100" rx="6"/>
<text class="tt ta" x="34" y="224">1 · Setting</text>
<text class="ts" x="34" y="244">a value differs</text>
<text class="tm" x="34" y="262">RTGS minimum</text>
<text class="ta" x="34" y="282">changes live</text>
<rect class="box-a" x="215" y="175" width="180" height="125" rx="6"/>
<text class="tt ta" x="229" y="199">2 · Server challenge</text>
<text class="ts" x="229" y="219">security steps differ</text>
<text class="tm" x="229" y="237">TPIN → OTP</text>
<text class="ta" x="229" y="257">changes live</text>
<rect class="box-a" x="410" y="150" width="180" height="150" rx="6"/>
<text class="tt ta" x="424" y="174">3 · Journey variant</text>
<text class="ts" x="424" y="194">journey steps differ</text>
<text class="tm" x="424" y="212">loan form steps</text>
<text class="ta" x="424" y="232">switch live, add by OTA</text>
<rect class="box" x="605" y="125" width="180" height="175" rx="6"/>
<text class="tt" x="619" y="149">4 · Strategy</text>
<text class="ts" x="619" y="169">a rule differs</text>
<text class="tm" x="619" y="187">account number format</text>
<text x="619" y="207">needs OTA</text>
<rect class="box" x="800" y="100" width="180" height="200" rx="6"/>
<text class="tt" x="814" y="124">5 · Slot override</text>
<text class="ts" x="814" y="144">a look differs</text>
<text class="tm" x="814" y="162">Home header</text>
<text x="814" y="182">needs OTA</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Can change without an app update</span><span><i class="sw"></i>Needs an OTA update</span></div>
</div>
<figcaption>Most bank requests stop at rung 1 or 2. Rungs 4 and 5 are built only when a real bank needs them.</figcaption>
</figure>
<div class="tbl">
<table>
<thead><tr><th>Rung</th><th>Use it when</th><th>How it works</th></tr></thead>
<tbody>
<tr><td>1 · Setting</td><td>Only a value differs: a limit, a timeout, a PIN length</td><td>Typed settings in the pack, overridable by the bank's backend; for security timeouts the stricter value wins</td></tr>
<tr><td>2 · Server challenge</td><td>Security steps differ: TPIN, OTP, biometric, step-up by amount, cooling periods</td><td>The bank's backend names the next check; the app only knows how to show each kind of check</td></tr>
<tr><td>3 · Journey variant</td><td>A form-style journey has different steps or order</td><td>Named step lists compiled into the app; the bank picks one</td></tr>
<tr><td>4 · Strategy</td><td>Same screens, different rule or integration</td><td>A small interface with a shared default; the bank's pack overrides one part</td></tr>
<tr><td>5 · Slot override</td><td>A section must look different</td><td>The bank's pack supplies its own component for one named slot</td></tr>
</tbody>
</table>
</div>
<h3>Security steps come from the server</h3>
<div class="prose"><p>If the app decided "Bank B skips the TPIN", a modified app could skip it anywhere. So each bank's backend holds its policy and replies one step at a time: "now the TPIN", "now the OTP", "done".</p></div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 250" role="img" aria-label="The same transfer in three banks. Bank A: confirm, TPIN, OTP, done. Bank B for 3,000 rupees: confirm, OTP, done. Bank C: confirm, biometric, TPIN, done. The app code is identical; only the server policy differs.">
<defs>
<marker id="d6-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="ts" x="180" y="22">Each arrow is one server reply that names the next check.</text>
<text class="tt" x="20" y="58">Bank A</text>
<text class="ts" x="20" y="76">policy: TPIN, then OTP</text>
<rect class="box" x="180" y="40" width="130" height="44" rx="6"/><text class="tt" x="245" y="67" text-anchor="middle">Confirm</text>
<line class="edge" x1="310" y1="62" x2="348" y2="62" marker-end="url(#d6-m)"/>
<rect class="box-a" x="350" y="40" width="130" height="44" rx="6"/><text class="tt ta" x="415" y="67" text-anchor="middle">TPIN</text>
<line class="edge" x1="480" y1="62" x2="518" y2="62" marker-end="url(#d6-m)"/>
<rect class="box-a" x="520" y="40" width="130" height="44" rx="6"/><text class="tt ta" x="585" y="67" text-anchor="middle">OTP</text>
<line class="edge" x1="650" y1="62" x2="688" y2="62" marker-end="url(#d6-m)"/>
<rect class="box" x="690" y="40" width="130" height="44" rx="6"/><text class="tt" x="755" y="67" text-anchor="middle">Done</text>
<text class="tt" x="20" y="128">Bank B · ₹3,000</text>
<text class="ts" x="20" y="146">OTP only up to ₹5,000</text>
<rect class="box" x="180" y="110" width="130" height="44" rx="6"/><text class="tt" x="245" y="137" text-anchor="middle">Confirm</text>
<line class="edge" x1="310" y1="132" x2="348" y2="132" marker-end="url(#d6-m)"/>
<rect class="box-a" x="350" y="110" width="130" height="44" rx="6"/><text class="tt ta" x="415" y="137" text-anchor="middle">OTP</text>
<line class="edge" x1="480" y1="132" x2="518" y2="132" marker-end="url(#d6-m)"/>
<rect class="box" x="520" y="110" width="130" height="44" rx="6"/><text class="tt" x="585" y="137" text-anchor="middle">Done</text>
<text class="tt" x="20" y="198">Bank C</text>
<text class="ts" x="20" y="216">biometric, then TPIN</text>
<rect class="box" x="180" y="180" width="130" height="44" rx="6"/><text class="tt" x="245" y="207" text-anchor="middle">Confirm</text>
<line class="edge" x1="310" y1="202" x2="348" y2="202" marker-end="url(#d6-m)"/>
<rect class="box-a" x="350" y="180" width="130" height="44" rx="6"/><text class="tt ta" x="415" y="207" text-anchor="middle">Biometric</text>
<line class="edge" x1="480" y1="202" x2="518" y2="202" marker-end="url(#d6-m)"/>
<rect class="box-a" x="520" y="180" width="130" height="44" rx="6"/><text class="tt ta" x="585" y="207" text-anchor="middle">TPIN</text>
<line class="edge" x1="650" y1="202" x2="688" y2="202" marker-end="url(#d6-m)"/>
<rect class="box" x="690" y="180" width="130" height="44" rx="6"/><text class="tt" x="755" y="207" text-anchor="middle">Done</text>
<rect class="grp" x="850" y="40" width="140" height="184" rx="8"/>
<text class="tt" x="866" y="70">Same app code</text>
<text class="ts" x="866" y="92">for all three banks.</text>
<text class="ts" x="866" y="112">Only the server</text>
<text class="ts" x="866" y="130">policy differs.</text>
<text class="ts" x="866" y="160">Bank B can move</text>
<text class="ts" x="866" y="178">₹5,000 to ₹10,000</text>
<text class="ts" x="866" y="196">with no app release.</text>
</svg>
<div class="legend"><span><i class="sw a"></i>A check the server asked for</span></div>
</div>
<figcaption>A biometric step is verified by the server: the app signs a one-time code with a key that only the customer's fingerprint or face unlocks. Older APIs that take a PIN and an OTP in fixed steps are wrapped by a small adapter, so the app works the same way everywhere.</figcaption>
</figure>
<div class="rule"><b>Not allowed:</b> <code>if (tenant.id === 'bank-b')</code> or <code>switch (tenant.orgId)</code> in shared code, a Git branch per bank, or copied screens. An automatic lint check fails the build on the first two.</div>
</section>
<!-- 07 RENDERING -->
<section id="rendering">
<h2><span class="num">07</span>How the app renders</h2>
<div class="prose"><p>The app never shows anything before it knows what this bank and this customer may use, and it never shows something the server would refuse.</p></div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 140" role="img" aria-label="Render path: app start with compiled defaults, cached bank config, a config fetch capped at a few seconds, the sign-in screen gated by flags, waiting for session flags after login, app tabs with only allowed routes, and each screen with gated parts and guarded actions.">
<defs>
<marker id="n8-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="th" x="20" y="26">SPLASH</text>
<line class="sep" x1="20" y1="36" x2="422" y2="36"/>
<text class="th" x="437" y="26">SIGN-IN SCREEN</text>
<line class="sep" x1="437" y1="36" x2="561" y2="36"/>
<text class="th" x="576" y="26">SPLASH</text>
<line class="sep" x1="576" y1="36" x2="700" y2="36"/>
<text class="th" x="715" y="26">APP</text>
<line class="sep" x1="715" y1="36" x2="978" y2="36"/>
<rect class="box" x="20" y="46" width="124" height="72" rx="6"/>
<text class="tt" x="32" y="72">App start</text>
<text class="ts" x="32" y="94">default + pack</text>
<line class="edge" x1="144" y1="82" x2="157" y2="82" marker-end="url(#n8-m)"/>
<rect class="box" x="159" y="46" width="124" height="72" rx="6"/>
<text class="tt" x="171" y="72">Cache</text>
<text class="ts" x="171" y="94">last bank config</text>
<line class="edge" x1="283" y1="82" x2="296" y2="82" marker-end="url(#n8-m)"/>
<rect class="box new" x="298" y="46" width="124" height="72" rx="6"/>
<text class="tt" x="310" y="72">Fetch config</text>
<text class="ts" x="310" y="94">max 4 s, orgId</text>
<line class="edge" x1="422" y1="82" x2="435" y2="82" marker-end="url(#n8-m)"/>
<rect class="box" x="437" y="46" width="124" height="72" rx="6"/>
<text class="tt" x="449" y="72">Sign-in</text>
<text class="ts" x="449" y="94">gated by flags</text>
<line class="edge" x1="561" y1="82" x2="574" y2="82" marker-end="url(#n8-m)"/>
<rect class="box new" x="576" y="46" width="124" height="72" rx="6"/>
<text class="tt" x="588" y="72">Session flags</text>
<text class="ts" x="588" y="94">role + customer</text>
<line class="edge" x1="700" y1="82" x2="713" y2="82" marker-end="url(#n8-m)"/>
<rect class="box" x="715" y="46" width="124" height="72" rx="6"/>
<text class="tt" x="727" y="72">App tabs</text>
<text class="ts" x="727" y="94">allowed routes</text>
<line class="edge" x1="839" y1="82" x2="852" y2="82" marker-end="url(#n8-m)"/>
<rect class="box" x="854" y="46" width="124" height="72" rx="6"/>
<text class="tt" x="866" y="72">Each screen</text>
<text class="ts" x="866" y="94">guarded actions</text>
</svg>
<div class="legend"><span><i class="sw n"></i>A short, bounded wait</span></div>
</div>
<figcaption>The cache makes the first step instant and keeps kill switches working offline. The two waits are short and bounded, so a weak network never leaves the customer on the splash screen.</figcaption>
</figure>
<div class="tbl">
<table>
<thead><tr><th>Rule</th><th>How</th></tr></thead>
<tbody>
<tr><td>Nothing shows before the app knows what is allowed</td><td>Cached bank config first; the config fetch is capped at a few seconds</td></tr>
<tr><td>No flash after sign-in</td><td>The app waits for the customer's flags, or receives them in the login response</td></tr>
<tr><td>A disabled feature leaves no trace</td><td>Its routes are not registered, its UI is not drawn and its actions are guarded; no greyed-out buttons</td></tr>
<tr><td>Money flows are never interrupted</td><td>A running payment holds a lease; a config change takes effect after it ends</td></tr>
<tr><td>Failures are contained</td><td>An error boundary at the root and on each tab; failures are reported with bank tags</td></tr>
<tr><td>Screens hold no bank rules</td><td>Flows, settings and strategies supply them, so a screen is the same for every bank</td></tr>
<tr><td>Branding comes from tokens</td><td>Colours, logo and fonts come from the pack through the theme and shared text components</td></tr>
<tr><td>Re-render only on real change</td><td>A config refresh that changes nothing changes nothing on screen</td></tr>
<tr><td>Test data never reaches customers</td><td>Mock data and test codes exist only in dev builds; CI checks every release build</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 08 DEVELOPMENT -->
<section id="development">
<h2><span class="num">08</span>Development flow</h2>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 280" role="img" aria-label="Development flow: a feature branch becomes a pull request with code review; CI runs nine checks; the change merges to main, which feeds the weekly JS train and the monthly native train; a release branch is cut at each store release for hotfixes.">
<defs>
<marker id="n4-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="20" y="107" width="130" height="56" rx="6"/>
<text class="tt" x="34" y="131">Feature branch</text>
<text class="tm" x="34" y="150">feat/…</text>
<line class="edge" x1="150" y1="135" x2="172" y2="135" marker-end="url(#n4-m)"/>
<rect class="box" x="175" y="107" width="120" height="56" rx="6"/>
<text class="tt" x="189" y="131">Pull request</text>
<text class="ts" x="189" y="150">+ code review</text>
<line class="edge" x1="295" y1="135" x2="317" y2="135" marker-end="url(#n4-m)"/>
<rect class="box" x="320" y="20" width="240" height="240" rx="6"/>
<text class="tt" x="334" y="44">CI checks on every PR</text>
<text class="ts" x="334" y="70">• lint + no-bank-branching rule</text>
<text class="ts" x="334" y="91">• type check</text>
<text class="ts" x="334" y="112">• unit + component tests</text>
<text class="ts" x="334" y="133">• feature audit, strict</text>
<text class="ts" x="334" y="154">• new keys ship off</text>
<text class="ts" x="334" y="175">• tenant matrix + contrast</text>
<text class="ts" x="334" y="196">• bundle builds for every bank</text>
<text class="ts" x="334" y="217">• no test data in release builds</text>
<text class="ts" x="334" y="238">• publish feature catalog</text>
<line class="edge" x1="560" y1="135" x2="582" y2="135" marker-end="url(#n4-m)"/>
<rect class="box" x="585" y="107" width="110" height="56" rx="6"/>
<text class="tt" x="599" y="131">main</text>
<text class="ts" x="599" y="150">releasable</text>
<path class="edge" d="M695 125 H714 V70 H732" marker-end="url(#n4-m)"/>
<path class="edge" d="M695 145 H714 V210 H732" marker-end="url(#n4-m)"/>
<rect class="box-a" x="735" y="40" width="245" height="60" rx="6"/>
<text class="tt ta" x="749" y="64">Weekly JS train</text>
<text class="ts" x="749" y="84">OTA to each bank's channels</text>
<rect class="box" x="735" y="180" width="245" height="60" rx="6"/>
<text class="tt" x="749" y="204">Monthly native train</text>
<text class="ts" x="749" y="224">store build in each bank's account</text>
<line class="edge" x1="640" y1="163" x2="640" y2="198" marker-end="url(#n4-m)"/>
<text class="ts" x="648" y="185">cut</text>
<rect class="box" x="585" y="200" width="110" height="48" rx="6"/>
<text class="tm" x="599" y="220">release/x.y</text>
<text class="ts" x="599" y="238">hotfixes</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Reaches phones without a store release</span></div>
</div>
<figcaption>Nothing merges unless every check passes. Fixes for an older native version are cherry-picked onto its <code>release/x.y</code> branch and ship on the same OTA train.</figcaption>
</figure>
<div class="tbl">
<table>
<thead><tr><th>Branch or tag</th><th>Purpose</th><th>Lifetime</th></tr></thead>
<tbody>
<tr><td><code>main</code></td><td>Always releasable; the weekly JS train is cut from here</td><td>Permanent</td></tr>
<tr><td><code>feat/…</code></td><td>One change, merged by pull request</td><td>Days</td></tr>
<tr><td><code>release/1.6</code></td><td>Created at each store release; hotfixes for that native version</td><td>Until 1.6 leaves support</td></tr>
<tr><td><code>v1.6.0</code></td><td>Tag on every store release</td><td>Permanent</td></tr>
<tr><td><code>ota-2026-11-05</code></td><td>Tag on every JS train</td><td>Permanent</td></tr>
</tbody>
</table>
</div>
<div class="prose"><p>Working locally, one bank at a time:</p></div>
<pre><code>npm run start:bank-a # Metro for Bank A, dev environment
npm run android:bank-a # installs bankADevDebug on a device
npm run start:bank-b -- --reset-cache # switch to Bank B</code></pre>
<div class="prose">
<p><b>Rules for developers</b></p>
<ul>
<li>Never branch on the bank. Use a setting, the server policy, a journey variant, a strategy or a slot.</li>
<li>Every new screen, tab, button and money action has a feature key, and the key ships off.</li>
<li>Values a bank might change are settings, not constants.</li>
<li>Security steps go through the flow framework, never a hand-written step machine.</li>
<li>Test data lives in the dev environment only.</li>
<li>Bank-specific code lives in that bank's pack, with a note in the pack's README saying why.</li>
</ul>
<p><b>A feature is done when</b> its key ships off; its route, UI and actions are gated; the server endpoint maps to the key; tests cover the flow; every CI check passes; the catalog is published; and the bank-facing notes are written.</p>
</div>
</section>
<!-- 09 DEPLOYMENT -->
<section id="deployment">
<h2><span class="num">09</span>Deployment flow</h2>
<h3>App builds per bank</h3>
<div class="tbl">
<table>
<thead><tr><th>Build variant</th><th>Environment</th><th>Signed with</th><th>Installed from</th><th>OTA channel</th></tr></thead>
<tbody>
<tr><td><code>bankADevDebug</code></td><td>Dev</td><td>Debug key</td><td>Developer machine</td><td>none</td></tr>
<tr><td><code>bankAUatRelease</code></td><td>UAT</td><td>Bank A's upload key</td><td>Internal track, TestFlight</td><td><code>bank-a-uat</code></td></tr>
<tr><td><code>bankAProdRelease</code></td><td>Prod</td><td>Bank A's upload key</td><td>Play Store, App Store</td><td><code>bank-a-prod</code></td></tr>
</tbody>
</table>
</div>
<h3>Native release train</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 206" role="img" aria-label="Monthly native release train: cut the release branch and bump versions, build UAT for each bank, bank UAT on the internal track or TestFlight, submit the production build to the bank's own store account, roll out from 10 to 100 percent, later raise the minimum version. A bug found in UAT is fixed on the release branch and rebuilt.">
<defs>
<marker id="n5-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="12" y="40" width="146" height="90" rx="6"/>
<text class="tt" x="26" y="64">1 · Cut release</text>
<text class="tm" x="26" y="86">release/1.6</text>
<text class="ts" x="26" y="106">versions bumped</text>
<line class="edge" x1="158" y1="85" x2="176" y2="85" marker-end="url(#n5-m)"/>
<rect class="box-a" x="178" y="40" width="146" height="90" rx="6"/>
<text class="tt ta" x="192" y="64">2 · UAT builds</text>
<text class="tm" x="192" y="86">bankXUatRelease</text>
<text class="ts" x="192" y="106">CI, each bank</text>
<line class="edge" x1="324" y1="85" x2="342" y2="85" marker-end="url(#n5-m)"/>
<rect class="box-a" x="344" y="40" width="146" height="90" rx="6"/>
<text class="tt ta" x="358" y="64">3 · Bank UAT</text>
<text class="ts" x="358" y="86">internal track,</text>
<text class="ts" x="358" y="106">TestFlight</text>
<line class="edge" x1="490" y1="85" x2="508" y2="85" marker-end="url(#n5-m)"/>
<rect class="box-a" x="510" y="40" width="146" height="90" rx="6"/>
<text class="tt ta" x="524" y="64">4 · Submit</text>
<text class="ts" x="524" y="86">prod build to the</text>
<text class="ts" x="524" y="106">bank's own account</text>
<line class="edge" x1="656" y1="85" x2="674" y2="85" marker-end="url(#n5-m)"/>
<rect class="box-a" x="676" y="40" width="146" height="90" rx="6"/>
<text class="tt ta" x="690" y="64">5 · Roll out</text>
<text class="tm" x="690" y="86">10% → 100%</text>
<text class="ts" x="690" y="106">watch crash-free</text>
<line class="edge" x1="822" y1="85" x2="840" y2="85" marker-end="url(#n5-m)"/>
<rect class="box" x="842" y="40" width="146" height="90" rx="6"/>
<text class="tt" x="856" y="64">6 · Min version</text>
<text class="ts" x="856" y="86">raise it later;</text>
<text class="ts" x="856" y="106">old apps update</text>
<path class="edge" d="M417 130 V166 H251 V133" marker-end="url(#n5-m)"/>
<text class="ts" x="334" y="186" text-anchor="middle">bug found: fix on release branch, rebuild</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Runs once per bank, on that bank's sign-off</span></div>
</div>
<figcaption>Each bank moves through steps 2 to 5 on its own schedule. CI uploads to each bank's store account with credentials the bank grants.</figcaption>
</figure>
<h3>Backend deployment, one bank at a time</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 240" role="img" aria-label="Backend deployment: a backend pull request passes CI and becomes one image, backend 2.4.0, which is deployed to each bank's UAT backend; after that bank signs off, the same image goes to that bank's production backend.">
<defs>
<marker id="n9-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="20" y="96" width="130" height="56" rx="6"/>
<text class="tt" x="34" y="120">Backend PR</text>
<text class="ts" x="34" y="139">one codebase</text>
<line class="edge" x1="150" y1="124" x2="173" y2="124" marker-end="url(#n9-m)"/>
<rect class="box" x="175" y="96" width="140" height="56" rx="6"/>
<text class="tt" x="189" y="120">CI + tests</text>
<text class="ts" x="189" y="139">migration checks</text>
<line class="edge" x1="315" y1="124" x2="338" y2="124" marker-end="url(#n9-m)"/>
<rect class="box" x="340" y="96" width="150" height="56" rx="6"/>
<text class="tt" x="354" y="120">One image</text>
<text class="tm" x="354" y="139">backend:2.4.0</text>
<path class="edge" d="M490 124 H525 V50 H557" marker-end="url(#n9-m)"/>
<line class="edge" x1="490" y1="124" x2="557" y2="124" marker-end="url(#n9-m)"/>
<path class="edge" d="M490 124 H525 V198 H557" marker-end="url(#n9-m)"/>
<rect class="box-a" x="560" y="28" width="150" height="44" rx="6"/>
<text class="tt ta" x="574" y="47">Bank A · UAT</text>
<text class="ts" x="574" y="63">bank tests here</text>
<rect class="box-a" x="560" y="102" width="150" height="44" rx="6"/>
<text class="tt ta" x="574" y="121">Bank B · UAT</text>
<text class="ts" x="574" y="137">bank tests here</text>
<rect class="box-a" x="560" y="176" width="150" height="44" rx="6"/>
<text class="tt ta" x="574" y="195">Bank C · UAT</text>
<text class="ts" x="574" y="211">bank tests here</text>
<line class="edge" x1="710" y1="50" x2="797" y2="50" marker-end="url(#n9-m)"/>
<text class="ts" x="754" y="42" text-anchor="middle">sign-off</text>
<line class="edge" x1="710" y1="124" x2="797" y2="124" marker-end="url(#n9-m)"/>
<text class="ts" x="754" y="116" text-anchor="middle">sign-off</text>
<line class="edge" x1="710" y1="198" x2="797" y2="198" marker-end="url(#n9-m)"/>
<text class="ts" x="754" y="190" text-anchor="middle">sign-off</text>
<rect class="box-a" x="800" y="28" width="180" height="44" rx="6"/>
<text class="tt ta" x="814" y="47">Bank A · production</text>
<text class="ts" x="814" y="63">rolling deploy</text>
<rect class="box-a" x="800" y="102" width="180" height="44" rx="6"/>
<text class="tt ta" x="814" y="121">Bank B · production</text>
<text class="ts" x="814" y="137">rolling deploy</text>
<rect class="box-a" x="800" y="176" width="180" height="44" rx="6"/>
<text class="tt ta" x="814" y="195">Bank C · production</text>
<text class="ts" x="814" y="211">rolling deploy</text>
</svg>
<div class="legend"><span><i class="sw a"></i>A separate deployment for each bank</span></div>
</div>
<figcaption>Every bank runs the same image; only configuration differs. Each bank moves to production on its own sign-off, and at most two backend versions run in production at once.</figcaption>
</figure>
<div class="prose">
<ul>
<li><b>One image everywhere.</b> Banks differ only in configuration and secrets: core-banking endpoint, database, key pair, certificates and org id.</li>
<li><b>Infrastructure as code.</b> One script creates a new bank's UAT and production backends. The target is one day per bank.</li>
<li><b>Expand, then contract.</b> Database and API changes are added first and removed later, so the previous backend version and older apps keep working during a rollout.</li>
<li><b>Each backend reports itself.</b> <code>GET /meta</code> returns its version and capabilities, and the release registry records which version each bank runs.</li>
<li><b>Each backend serves one bank.</b> It accepts only its own org id and refuses anything else.</li>
</ul>
<p><b>Versions</b></p>
<ul>
<li>Each bank's app version lives in <code>tenants/registry.json</code> as <code>MAJOR.MINOR.PATCH</code>. A native release bumps MINOR; a native hotfix bumps PATCH.</li>
<li>The store version code is computed: <code>MAJOR × 10000 + MINOR × 100 + PATCH</code>, so 1.6.2 becomes 10602.</li>
<li>OTA bundles target exact native versions (<code>-t 1.6.2</code>), never wide ranges.</li>
<li>We support the last two minor app versions; below that, the server's minimum version sends the customer to the store.</li>
</ul>
<p><b>Signing and secrets</b></p>
<ul>
<li>One upload key per bank, stored only in CI secrets and backed up offline.</li>
<li>Google Play App Signing per bank app; iOS certificates in each bank's Apple account.</li>
<li>OTA credentials per environment; only CI may deploy to production channels.</li>
</ul>
<p><b>Keeping app and backend compatible</b></p>
<ul>
<li>A bank's backend is upgraded before any app feature that needs it is switched on for that bank.</li>
<li>API changes are additive. A field is removed only after every bank's backend and every supported app version have moved past it.</li>
<li>The config response carries a schema version, and the app ignores keys it does not know.</li>
</ul>
</div>
</section>
<!-- 10 OTA -->
<section id="ota">
<h2><span class="num">10</span>OTA flow</h2>
<div class="prose"><p>OTA updates replace the app's JavaScript and images without a store release. Every bank has two channels: <code>bank-x-uat</code>, installed by its testers, and <code>bank-x-prod</code>, installed by its customers. The channel is baked into the app build, so a bundle for Bank B can never reach Bank A.</p></div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 226" role="img" aria-label="OTA pipeline: pull request, CI checks, build per bank, UAT channels, bank sign-off, production rollout from 10 to 100 percent. On a problem, disable the bundle so phones revert, then fix forward with a new pull request.">
<defs>
<marker id="d9-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d9-mk" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-k" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box" x="20" y="50" width="100" height="64" rx="6"/>
<text class="tt" x="34" y="76">Pull request</text>
<text class="ts" x="34" y="95">one change</text>
<line class="edge" x1="120" y1="82" x2="143" y2="82" marker-end="url(#d9-m)"/>
<rect class="box" x="145" y="50" width="160" height="64" rx="6"/>
<text class="tt" x="159" y="76">CI checks</text>
<text class="ts" x="159" y="95">lint · types · tests</text>
<line class="edge" x1="305" y1="82" x2="328" y2="82" marker-end="url(#d9-m)"/>
<rect class="box" x="330" y="50" width="150" height="64" rx="6"/>
<text class="tt" x="344" y="76">Build per bank</text>
<text class="tm" x="344" y="95">TENANT=bank-x</text>
<line class="edge" x1="480" y1="82" x2="503" y2="82" marker-end="url(#d9-m)"/>
<rect class="box-a" x="505" y="50" width="140" height="64" rx="6"/>
<text class="tt ta" x="519" y="76">UAT channels</text>
<text class="tm" x="519" y="95">bank-x-uat</text>
<line class="edge" x1="645" y1="82" x2="668" y2="82" marker-end="url(#d9-m)"/>
<rect class="box-a" x="670" y="50" width="110" height="64" rx="6"/>
<text class="tt ta" x="684" y="76">Bank sign-off</text>
<text class="ts" x="684" y="95">per bank</text>
<line class="edge" x1="780" y1="82" x2="803" y2="82" marker-end="url(#d9-m)"/>
<rect class="box-a" x="805" y="50" width="185" height="64" rx="6"/>
<text class="tt ta" x="819" y="76">Production</text>
<text class="tm" x="819" y="95">10% → 50% → 100%</text>
<line class="edge-k" x1="897" y1="114" x2="897" y2="155" marker-end="url(#d9-mk)"/>
<text class="ts" x="905" y="140">problem</text>
<rect class="box-k" x="760" y="158" width="230" height="48" rx="6"/>
<text class="tt tk" x="774" y="178">Disable the bundle</text>
<text class="ts" x="774" y="196">phones revert to the last bundle</text>
<path class="edge" d="M760 182 H70 V117" marker-end="url(#d9-m)"/>
<text class="ts" x="415" y="200" text-anchor="middle">fix forward with a new pull request</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Runs once per bank</span><span><i class="sw k"></i>Rollback</span></div>
</div>
<figcaption>If a bundle crashes before the first screen, the OTA client rolls that phone back by itself. Later crashes are caught by error boundaries and reported, and the release manager disables the bundle.</figcaption>
</figure>
<h3>The weekly JS train</h3>
<div class="tbl">
<table>
<thead><tr><th>Day</th><th>Step</th><th>Who</th></tr></thead>
<tbody>
<tr><td>Monday</td><td>Cut from <code>main</code>; CI builds a bundle per bank and per supported native version, and deploys to every <code>-uat</code> channel at 100%</td><td>CI, release manager</td></tr>
<tr><td>Tuesday–Wednesday</td><td>Each bank tests on its UAT app</td><td>Bank QA, our QA</td></tr>
<tr><td>Wednesday</td><td>Sign-off per bank, recorded in the release ticket</td><td>Bank</td></tr>
<tr><td>Thursday</td><td>Production at 10%, only for banks that signed off</td><td>Release manager</td></tr>
<tr><td>Friday</td><td>50%, if crash-free sessions stay at or above 99.5% and no error rate jumps</td><td>Release manager</td></tr>
<tr><td>Next Monday</td><td>100%</td><td>Release manager</td></tr>
</tbody>
</table>
</div>
<div class="prose">
<ul>
<li><b>Hotfix:</b> the same path compressed into one day: a UAT smoke test within two hours, then 10% and 100% the same day.</li>
<li><b>Rollback levels:</b> automatic (crash before the first screen); disable one channel's bundle; disable a release on every channel with one script; then fix forward.</li>
<li><b>A bank that has not signed off</b> stays on its previous bundle. It may lag at most two trains before the release manager escalates.</li>
</ul>
</div>
<h3>Keeping versions compatible</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 240" role="img" aria-label="Versions over time: store release 1.4.0 receives OTA fixes targeted with -t 1.4.0; store release 1.5.0 receives its own fixes with -t 1.5.0; raising the minimum version to 1.5.0 forces phones still on 1.4 to update from the store.">
<defs>
<marker id="d10-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
<marker id="d10-ma" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah-a" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="tt" x="20" y="64">Store (native)</text>
<text class="tt" x="20" y="126">OTA (JS)</text>
<text class="tt" x="20" y="184">Forced update</text>
<rect class="box" x="160" y="40" width="120" height="40" rx="6"/>
<text class="tt" x="220" y="65" text-anchor="middle">v1.4.0</text>
<rect class="box" x="600" y="40" width="120" height="40" rx="6"/>
<text class="tt" x="660" y="65" text-anchor="middle">v1.5.0</text>
<path class="edge-a" d="M220 80 V122 H308" marker-end="url(#d10-ma)"/>
<text class="tm" x="264" y="114" text-anchor="middle">-t 1.4.0</text>
<rect class="box-a" x="310" y="104" width="130" height="36" rx="6"/>
<text class="ta" x="375" y="127" text-anchor="middle">fix 1 for 1.4.0</text>
<line class="edge-a" x1="440" y1="122" x2="458" y2="122" marker-end="url(#d10-ma)"/>
<rect class="box-a" x="460" y="104" width="130" height="36" rx="6"/>
<text class="ta" x="525" y="127" text-anchor="middle">fix 2 for 1.4.0</text>
<path class="edge-a" d="M660 80 V122 H748" marker-end="url(#d10-ma)"/>
<text class="tm" x="704" y="114" text-anchor="middle">-t 1.5.0</text>
<rect class="box-a" x="750" y="104" width="130" height="36" rx="6"/>
<text class="ta" x="815" y="127" text-anchor="middle">fix 1 for 1.5.0</text>
<rect class="box-k" x="600" y="162" width="390" height="40" rx="6"/>
<text class="tk" x="614" y="187">Min version → 1.5.0: phones on 1.4.x must update from the store</text>
<line class="edge" x1="160" y1="224" x2="980" y2="224" marker-end="url(#d10-m)"/>
<text class="ts" x="150" y="228" text-anchor="end">time</text>
</svg>
</div>
<figcaption>An OTA bundle reaches only the native version it was built for, so JavaScript never runs on a binary that lacks its native code.</figcaption>
</figure>
<div class="note"><b>OTA goes live only with its safety net:</b> error boundaries, crash reporting tagged by bank and bundle, signed builds, <code>x.y.z</code> versions, signed bundles and the tenant guard. Without them, a staged rollout cannot see what it is doing.</div>
</section>
<!-- 11 OPERATIONS -->
<section id="operations">
<h2><span class="num">11</span>Operations flow</h2>
<h3>Changing a bank's switch</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 320" role="img" aria-label="Config change with two-person approval: a maker at Bank B proposes loans off; Bank B's backend asks the checker to approve; the checker approves; the audit log records who, what, when and version 44; the customer app fetches config on resume and receives version 44, and the Loans tab is hidden.">
<defs>
<marker id="n6-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="box-a" x="25" y="20" width="150" height="46" rx="6"/>
<text class="tt ta" x="100" y="39" text-anchor="middle">Maker</text>
<text class="ts" x="100" y="56" text-anchor="middle">Bank B staff</text>
<rect class="box-a" x="215" y="20" width="150" height="46" rx="6"/>
<text class="tt ta" x="290" y="39" text-anchor="middle">Checker</text>
<text class="ts" x="290" y="56" text-anchor="middle">Bank B staff</text>
<rect class="box" x="425" y="20" width="150" height="46" rx="6"/>
<text class="tt" x="500" y="39" text-anchor="middle">Bank B backend</text>
<text class="ts" x="500" y="56" text-anchor="middle">config service</text>
<rect class="box" x="625" y="20" width="150" height="46" rx="6"/>
<text class="tt" x="700" y="39" text-anchor="middle">Audit log</text>
<text class="ts" x="700" y="56" text-anchor="middle">in Bank B's backend</text>
<rect class="box-a" x="825" y="20" width="150" height="46" rx="6"/>
<text class="tt ta" x="900" y="39" text-anchor="middle">Customer app</text>
<text class="ts" x="900" y="56" text-anchor="middle">Bank B phone</text>
<line class="life" x1="100" y1="66" x2="100" y2="316"/>
<line class="life" x1="290" y1="66" x2="290" y2="316"/>
<line class="life" x1="500" y1="66" x2="500" y2="316"/>
<line class="life" x1="700" y1="66" x2="700" y2="316"/>
<line class="life" x1="900" y1="66" x2="900" y2="316"/>
<line class="edge" x1="100" y1="100" x2="497" y2="100" marker-end="url(#n6-m)"/>
<text class="ts" x="195" y="92" text-anchor="middle">propose: loans off</text>
<line class="edge" x1="500" y1="140" x2="293" y2="140" marker-end="url(#n6-m)"/>
<text class="ts" x="395" y="132" text-anchor="middle">ask to approve</text>
<line class="edge" x1="290" y1="180" x2="497" y2="180" marker-end="url(#n6-m)"/>
<text class="ts" x="395" y="172" text-anchor="middle">approve</text>
<line class="edge" x1="500" y1="220" x2="697" y2="220" marker-end="url(#n6-m)"/>
<text class="ts" x="600" y="212" text-anchor="middle">who, what, when, v44</text>
<line class="edge" x1="900" y1="260" x2="503" y2="260" marker-end="url(#n6-m)"/>
<text class="ts" x="800" y="252" text-anchor="middle">GET config on resume</text>
<line class="ret" x1="500" y1="300" x2="897" y2="300" marker-end="url(#n6-m)"/>
<text class="ts" x="800" y="292" text-anchor="middle">v44 → Loans tab hidden</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Bank B's side</span><span><i class="sw d"></i>Reply</span></div>
</div>
<figcaption>A change takes effect within minutes and can be undone the same way. Our ops team can apply an emergency kill to any bank without a checker; that action is audited and the bank is told.</figcaption>
</figure>
<h3>Who owns what</h3>
<div class="tbl">
<table>
<thead><tr><th>Task</th><th>Platform team (us)</th><th>Bank</th><th>DevOps</th></tr></thead>
<tbody>
<tr><td>Feature scope</td><td>advises</td><td><b>decides</b></td><td>—</td></tr>
<tr><td>Tenant pack, branding</td><td><b>builds</b></td><td>approves</td><td>—</td></tr>
<tr><td>Store listing and developer account</td><td>helps</td><td><b>owns</b></td><td>—</td></tr>
<tr><td>Signing keys, CI secrets</td><td>—</td><td>grants account access</td><td><b>owns</b></td></tr>
<tr><td>The bank's backend environment</td><td>builds and deploys to it</td><td><b>owns it</b> if it runs in the bank's data centre</td><td><b>runs it</b> if it runs in our cloud</td></tr>
<tr><td>Backend upgrades</td><td><b>builds</b>, tests</td><td>signs off</td><td>deploys</td></tr>
<tr><td>Live feature switches</td><td>emergency kill only</td><td><b>owns</b></td><td>—</td></tr>
<tr><td>Customer rules (KYC, account type, new device)</td><td>configures</td><td><b>decides</b></td><td>—</td></tr>
<tr><td>Security policy</td><td>configures, reviews</td><td><b>decides</b>, signs off</td><td>—</td></tr>
<tr><td>UAT testing</td><td>supports</td><td><b>owns</b></td><td>—</td></tr>
<tr><td>OTA releases</td><td><b>owns</b></td><td>signs off</td><td>runs the pipeline</td></tr>
<tr><td>Store releases</td><td>builds</td><td><b>approves</b> in its account</td><td>uploads via CI</td></tr>
<tr><td>Monitoring</td><td><b>owns</b> app health</td><td>sees its own dashboard</td><td><b>owns</b> infrastructure</td></tr>
</tbody>
</table>
</div>
<h3>Who can change what</h3>
<div class="tbl">
<table>
<thead><tr><th>Tool</th><th>Used by</th><th>Can change</th><th>Cannot change</th></tr></thead>
<tbody>
<tr><td>Bank admin portal</td><td>Bank staff, two-person approval; runs inside the bank's own backend</td><td>Its own bank's switches, settings, journey variants, customer blocks</td><td>Other banks (separate deployments), security policy, app code</td></tr>
<tr><td>Platform ops console</td><td>Our operations team, over a private, audited link to each bank's backend</td><td>Onboarding, security policies, emergency kill for any bank, support lookup</td><td>App code</td></tr>
<tr><td>Git and CI</td><td>Our developers</td><td>Shared code, tenant packs, flow adapters</td><td>Live switches</td></tr>
<tr><td>OTA console</td><td>Release managers</td><td>Rollout percentage, disable or roll back a bundle</td><td>Native code</td></tr>
<tr><td>Play Console, App Store Connect</td><td>Bank, with our help</td><td>Store listing, staged rollout, halt</td><td>—</td></tr>
</tbody>
</table>
</div>
<h3>Watching each bank</h3>
<div class="prose"><p>Every crash report, API log and event carries five tags: <code>tenant</code>, <code>appVersion</code>, <code>bundleId</code>, <code>configVersion</code> and <code>channel</code>. Each bank's backend sends its metrics and logs, without customer data, to central monitoring. Thresholds below are starting points.</p></div>
<div class="tbl">
<table>
<thead><tr><th>Signal</th><th>Alert when</th><th>Action</th></tr></thead>
<tbody>
<tr><td>Crash-free sessions, per bank and bundle</td><td>Below 99.5% after a rollout step</td><td>Pause the rollout; disable the bundle if it keeps falling</td></tr>
<tr><td><code>FEATURE_NOT_AVAILABLE</code> rate</td><td>Rises after a release</td><td>The app shows something the server refuses; fix the gating</td></tr>
<tr><td>OTP and challenge failures, by type</td><td>Jump at one bank</td><td>Check that bank's SMS gateway or policy</td></tr>
<tr><td>Config fetch failures</td><td>Above 5%</td><td>Check that bank's backend and network</td></tr>
<tr><td>Phones below the minimum version</td><td>After a forced update</td><td>Bank sends a customer message</td></tr>
</tbody>
</table>
</div>
<div class="prose"><p><b>Support lookup:</b> in the ops console, a support agent enters a customer's mobile number and sees the bank, app version, bundle, config version and every feature with the layer that decided it. The same resolution logic runs in the app and on the server, so both always agree.</p></div>
<h3>When something goes wrong</h3>
<div class="tbl">
<table>
<thead><tr><th>Problem</th><th>Fastest fix</th><th>Takes effect</th><th>Who</th></tr></thead>
<tbody>
<tr><td>A feature misbehaves at one bank</td><td>Switch it off in the admin portal</td><td>Minutes</td><td>Bank admin or ops</td></tr>
<tr><td>A new JS bundle crashes on start</td><td>Nothing: the OTA client rolls that phone back</td><td>Next launch</td><td>Automatic</td></tr>
<tr><td>A new JS bundle has a bug</td><td>Disable the bundle in the OTA console</td><td>Minutes</td><td>Release manager</td></tr>
<tr><td>A security step must change now</td><td>Change that bank's security policy</td><td>Next transfer</td><td>Ops, with the bank</td></tr>
<tr><td>One bank's backend is down, or a backend release is bad</td><td>Roll that bank's backend back to the previous image; other banks are not affected</td><td>Minutes</td><td>DevOps, with the bank</td></tr>
<tr><td>A native bug in an old version</td><td>Ship a fixed store build, then raise the minimum version</td><td>Hours to days</td><td>Platform team, bank</td></tr>
<tr><td>A bad store build is rolling out</td><td>Halt the staged rollout in the store console</td><td>Hours</td><td>Bank's store account</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 12 ONE BANK -->
<section id="bank-update">
<h2><span class="num">12</span>One bank's update</h2>
<div class="prose"><p>Every change takes one of five lanes. The lane depends on what changed, not on urgency.</p></div>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 446" role="img" aria-label="Five update lanes. Switches and settings: made in the bank admin portal, approved by two people, delivered by config fetch, in minutes. Security policy: made in the flow engine, reviewed by ops and the bank, delivered in the next server reply, from the next transfer. Backend changes: a pull request to the backend codebase, CI and the bank's UAT backend, a rolling deploy to that bank's backend, from the next API call. JS, branding and pack changes: a pull request, CI and bank UAT, OTA, in hours to days. Native changes: a pull request, CI and store review, a store update with forced update, in weeks.">
<defs>
<marker id="d8-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<text class="th" x="20" y="30">CHANGE</text>
<text class="th" x="230" y="30">MADE IN</text>
<text class="th" x="440" y="30">CHECKED BY</text>
<text class="th" x="650" y="30">TRAVELS VIA</text>
<text class="th" x="860" y="30">REACHES PHONES</text>
<rect class="box-a" x="20" y="46" width="180" height="60" rx="6"/><text class="tt ta" x="34" y="71">Switch or setting</text><text class="ts" x="34" y="89">bank level, live</text>
<line class="edge" x1="200" y1="76" x2="228" y2="76" marker-end="url(#d8-m)"/>
<rect class="box-a" x="230" y="46" width="180" height="60" rx="6"/><text class="tt ta" x="244" y="71">Bank admin portal</text><text class="ts" x="244" y="89">or platform ops</text>
<line class="edge" x1="410" y1="76" x2="438" y2="76" marker-end="url(#d8-m)"/>
<rect class="box-a" x="440" y="46" width="180" height="60" rx="6"/><text class="tt ta" x="454" y="71">Maker-checker</text><text class="ts" x="454" y="89">two people approve</text>
<line class="edge" x1="620" y1="76" x2="648" y2="76" marker-end="url(#d8-m)"/>
<rect class="box-a" x="650" y="46" width="180" height="60" rx="6"/><text class="tt ta" x="664" y="71">Config fetch</text><text class="ts" x="664" y="89">start · resume · sign-in</text>
<line class="edge" x1="830" y1="76" x2="858" y2="76" marker-end="url(#d8-m)"/>
<rect class="box-a" x="860" y="46" width="130" height="60" rx="6"/><text class="tt ta" x="874" y="71">Minutes</text><text class="ts" x="874" y="89">no app release</text>
<rect class="box-a" x="20" y="126" width="180" height="60" rx="6"/><text class="tt ta" x="34" y="151">Security policy</text><text class="ts" x="34" y="169">TPIN, OTP, step-up</text>
<line class="edge" x1="200" y1="156" x2="228" y2="156" marker-end="url(#d8-m)"/>
<rect class="box-a" x="230" y="126" width="180" height="60" rx="6"/><text class="tt ta" x="244" y="151">Flow engine policy</text><text class="ts" x="244" y="169">one per bank</text>
<line class="edge" x1="410" y1="156" x2="438" y2="156" marker-end="url(#d8-m)"/>
<rect class="box-a" x="440" y="126" width="180" height="60" rx="6"/><text class="tt ta" x="454" y="151">Ops review</text><text class="ts" x="454" y="169">+ bank sign-off</text>
<line class="edge" x1="620" y1="156" x2="648" y2="156" marker-end="url(#d8-m)"/>
<rect class="box-a" x="650" y="126" width="180" height="60" rx="6"/><text class="tt ta" x="664" y="151">Next server reply</text><text class="ts" x="664" y="169">inside the flow</text>
<line class="edge" x1="830" y1="156" x2="858" y2="156" marker-end="url(#d8-m)"/>
<rect class="box-a" x="860" y="126" width="130" height="60" rx="6"/><text class="tt ta" x="874" y="151">Next transfer</text><text class="ts" x="874" y="169">no app release</text>
<rect class="box-a" x="20" y="206" width="180" height="60" rx="6"/><text class="tt ta" x="34" y="231">Backend change</text><text class="ts" x="34" y="249">API, rules, fixes</text>
<line class="edge" x1="200" y1="236" x2="228" y2="236" marker-end="url(#d8-m)"/>
<rect class="box-a" x="230" y="206" width="180" height="60" rx="6"/><text class="tt ta" x="244" y="231">Pull request</text><text class="ts" x="244" y="249">backend codebase</text>
<line class="edge" x1="410" y1="236" x2="438" y2="236" marker-end="url(#d8-m)"/>
<rect class="box-a" x="440" y="206" width="180" height="60" rx="6"/><text class="tt ta" x="454" y="231">CI + bank UAT</text><text class="ts" x="454" y="249">bank's UAT backend</text>
<line class="edge" x1="620" y1="236" x2="648" y2="236" marker-end="url(#d8-m)"/>
<rect class="box-a" x="650" y="206" width="180" height="60" rx="6"/><text class="tt ta" x="664" y="231">That bank's backend</text><text class="ts" x="664" y="249">rolling deploy</text>
<line class="edge" x1="830" y1="236" x2="858" y2="236" marker-end="url(#d8-m)"/>
<rect class="box-a" x="860" y="206" width="130" height="60" rx="6"/><text class="tt ta" x="874" y="231">Next API call</text><text class="ts" x="874" y="249">no app release</text>
<rect class="box" x="20" y="286" width="180" height="60" rx="6"/><text class="tt" x="34" y="311">JS, branding, pack</text><text class="ts" x="34" y="329">screens, fixes, logos</text>
<line class="edge" x1="200" y1="316" x2="228" y2="316" marker-end="url(#d8-m)"/>
<rect class="box" x="230" y="286" width="180" height="60" rx="6"/><text class="tt" x="244" y="311">Pull request</text><text class="ts" x="244" y="329">app codebase</text>
<line class="edge" x1="410" y1="316" x2="438" y2="316" marker-end="url(#d8-m)"/>
<rect class="box" x="440" y="286" width="180" height="60" rx="6"/><text class="tt" x="454" y="311">CI + bank UAT</text><text class="ts" x="454" y="329">per-bank uat channel</text>
<line class="edge" x1="620" y1="316" x2="648" y2="316" marker-end="url(#d8-m)"/>
<rect class="box" x="650" y="286" width="180" height="60" rx="6"/><text class="tt" x="664" y="311">OTA update</text><text class="ts" x="664" y="329">staged 10% → 100%</text>
<line class="edge" x1="830" y1="316" x2="858" y2="316" marker-end="url(#d8-m)"/>
<rect class="box" x="860" y="286" width="130" height="60" rx="6"/><text class="tt" x="874" y="311">Hours to days</text><text class="ts" x="874" y="329">no store review</text>
<rect class="box" x="20" y="366" width="180" height="60" rx="6"/><text class="tt" x="34" y="391">Native change</text><text class="ts" x="34" y="409">SDK, icon, app ID</text>
<line class="edge" x1="200" y1="396" x2="228" y2="396" marker-end="url(#d8-m)"/>
<rect class="box" x="230" y="366" width="180" height="60" rx="6"/><text class="tt" x="244" y="391">Pull request</text><text class="ts" x="244" y="409">+ registry version</text>
<line class="edge" x1="410" y1="396" x2="438" y2="396" marker-end="url(#d8-m)"/>
<rect class="box" x="440" y="366" width="180" height="60" rx="6"/><text class="tt" x="454" y="391">CI + store review</text><text class="ts" x="454" y="409">Google, Apple</text>
<line class="edge" x1="620" y1="396" x2="648" y2="396" marker-end="url(#d8-m)"/>
<rect class="box" x="650" y="366" width="180" height="60" rx="6"/><text class="tt" x="664" y="391">Store update</text><text class="ts" x="664" y="409">+ forced update</text>
<line class="edge" x1="830" y1="396" x2="858" y2="396" marker-end="url(#d8-m)"/>
<rect class="box" x="860" y="366" width="130" height="60" rx="6"/><text class="tt" x="874" y="391">Weeks</text><text class="ts" x="874" y="409">store release</text>
</svg>
<div class="legend"><span><i class="sw a"></i>Server-side: no app release at all</span><span><i class="sw"></i>Needs a new app build</span></div>
</div>
<figcaption>The three blue lanes change only the bank's backend, so no app release is needed. Every lane that ships code, on the server or on the phone, passes through that bank's UAT first.</figcaption>
</figure>
<h3>A bug fix for Bank B, day by day</h3>
<figure>
<div class="panel">
<svg class="dg" viewBox="0 0 1000 320" role="img" aria-label="A Bank B bug fix through the week: Monday the fix is merged and the bundle is deployed to bank-b-uat; Tuesday UAT phones update; Wednesday Bank B QA signs off; Thursday the bundle goes to bank-b-prod at 10 percent; Friday, if metrics are fine, 50 percent; next Monday 100 percent. Crash data from customers' phones drives each rollout decision.">
<defs>
<marker id="n7-m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="ah" d="M0 0L10 5L0 10z"/></marker>
</defs>
<rect class="band" x="20" y="100" width="960" height="70"/>
<rect class="band" x="20" y="240" width="960" height="70"/>
<line class="sep" x1="20" y1="30" x2="980" y2="30"/>
<line class="sep" x1="20" y1="100" x2="980" y2="100"/>
<line class="sep" x1="20" y1="170" x2="980" y2="170"/>
<line class="sep" x1="20" y1="240" x2="980" y2="240"/>
<line class="sep" x1="20" y1="310" x2="980" y2="310"/>
<text class="th" x="215" y="20" text-anchor="middle">MON</text>
<text class="th" x="355" y="20" text-anchor="middle">TUE</text>
<text class="th" x="495" y="20" text-anchor="middle">WED</text>
<text class="th" x="635" y="20" text-anchor="middle">THU</text>
<text class="th" x="775" y="20" text-anchor="middle">FRI</text>
<text class="th" x="915" y="20" text-anchor="middle">NEXT MON</text>
<text class="tt" x="28" y="69">Platform team</text>
<text class="tt" x="28" y="139">CI · OTA server</text>
<text class="tt" x="28" y="209">Bank B testers</text>
<text class="tt" x="28" y="279">Bank B customers</text>
<rect class="box" x="155" y="43" width="120" height="44" rx="6"/>
<text class="tt" x="215" y="62" text-anchor="middle">Fix merged</text>
<text class="ts" x="215" y="78" text-anchor="middle">to main</text>
<rect class="box" x="715" y="43" width="120" height="44" rx="6"/>
<text class="tt" x="775" y="62" text-anchor="middle">Metrics OK?</text>
<text class="ts" x="775" y="78" text-anchor="middle">→ go to 50%</text>
<rect class="box" x="855" y="43" width="120" height="44" rx="6"/>
<text class="tt" x="915" y="62" text-anchor="middle">Still OK?</text>
<text class="ts" x="915" y="78" text-anchor="middle">→ go to 100%</text>
<rect class="box-a" x="155" y="113" width="120" height="44" rx="6"/>
<text class="tt ta" x="215" y="132" text-anchor="middle">Bundle built</text>
<text class="tm" x="215" y="148" text-anchor="middle">→ bank-b-uat</text>
<rect class="box-a" x="575" y="113" width="120" height="44" rx="6"/>
<text class="tt ta" x="635" y="132" text-anchor="middle">Deploy prod</text>
<text class="tm" x="635" y="148" text-anchor="middle">bank-b-prod 10%</text>
<rect class="box-a" x="715" y="113" width="120" height="44" rx="6"/>
<text class="tt ta" x="775" y="132" text-anchor="middle">Rollout 50%</text>
<text class="ts" x="775" y="148" text-anchor="middle">one click</text>
<rect class="box-a" x="855" y="113" width="120" height="44" rx="6"/>
<text class="tt ta" x="915" y="132" text-anchor="middle">Rollout 100%</text>
<text class="ts" x="915" y="148" text-anchor="middle">done</text>
<rect class="box" x="295" y="183" width="120" height="44" rx="6"/>
<text class="tt" x="355" y="202" text-anchor="middle">UAT phones</text>
<text class="ts" x="355" y="218" text-anchor="middle">update on launch</text>
<rect class="box" x="435" y="183" width="120" height="44" rx="6"/>
<text class="tt" x="495" y="202" text-anchor="middle">Bank B QA</text>
<text class="ts" x="495" y="218" text-anchor="middle">signs off</text>
<rect class="box" x="575" y="253" width="120" height="44" rx="6"/>
<text class="tt" x="635" y="272" text-anchor="middle">10% of phones</text>
<text class="ts" x="635" y="288" text-anchor="middle">on next open</text>
<rect class="box" x="715" y="253" width="120" height="44" rx="6"/>
<text class="tt" x="775" y="272" text-anchor="middle">50% of phones</text>
<text class="ts" x="775" y="288" text-anchor="middle">on next open</text>
<rect class="box" x="855" y="253" width="120" height="44" rx="6"/>
<text class="tt" x="915" y="272" text-anchor="middle">Every phone</text>
<text class="ts" x="915" y="288" text-anchor="middle">on next open</text>
<line class="edge" x1="215" y1="87" x2="215" y2="111" marker-end="url(#n7-m)"/>
<path class="edge" d="M275 135 H355 V181" marker-end="url(#n7-m)"/>
<line class="edge" x1="415" y1="205" x2="433" y2="205" marker-end="url(#n7-m)"/>
<path class="edge" d="M555 199 H565 V135 H573" marker-end="url(#n7-m)"/>
<line class="edge" x1="635" y1="157" x2="635" y2="251" marker-end="url(#n7-m)"/>
<line class="edge" x1="775" y1="87" x2="775" y2="111" marker-end="url(#n7-m)"/>
<line class="edge" x1="775" y1="157" x2="775" y2="251" marker-end="url(#n7-m)"/>
<line class="edge" x1="915" y1="87" x2="915" y2="111" marker-end="url(#n7-m)"/>
<line class="edge" x1="915" y1="157" x2="915" y2="251" marker-end="url(#n7-m)"/>
<path class="ret" d="M695 275 H705 V65 H713" marker-end="url(#n7-m)"/>
<path class="ret" d="M835 275 H845 V65 H853" marker-end="url(#n7-m)"/>
</svg>
<div class="legend"><span><i class="sw a"></i>Bank B's OTA channels</span><span><i class="sw d"></i>Crash and error data that drives the next step</span></div>
</div>
<figcaption>Bank A and Bank C run the same steps on their own channels and their own sign-off. If Bank B finds a problem at any point, the release manager disables the bundle and Bank B's phones return to the previous one.</figcaption>
</figure>
<h3>Every kind of change, for Bank B</h3>
<div class="tbl">
<table>
<thead><tr><th>Bank B wants…</th><th>Lane</th><th>Steps</th><th>Reaches customers</th><th>Who acts</th></tr></thead>
<tbody>
<tr><td>Loans off today, during an incident</td><td><span class="pill a">switch</span></td><td>Admin portal → second approver → config v+1 → phones refresh on resume</td><td>Minutes</td><td>Bank B admin</td></tr>
<tr><td>A lower RTGS minimum</td><td><span class="pill a">setting</span></td><td>Same as a switch</td><td>Minutes</td><td>Bank B admin</td></tr>
<tr><td>OTP only up to ₹5,000</td><td><span class="pill a">policy</span></td><td>Ops edits Bank B's policy on its UAT backend → Bank B tests → same policy in production</td><td>Next transfer after go-live</td><td>Ops, Bank B</td></tr>
<tr><td>A backend fix or a new API</td><td><span class="pill a">backend</span></td><td>Backend pull request → new image → Bank B's UAT backend → Bank B signs off → Bank B's production backend</td><td>Next API call after the deploy</td><td>Backend team, Bank B</td></tr>
<tr><td>A transfer-screen bug fixed</td><td><span class="pill">OTA</span></td><td>The week shown above</td><td>3–7 days; a hotfix the same day</td><td>Platform team, Bank B QA</td></tr>
<tr><td>New logo or colours</td><td><span class="pill">OTA</span></td><td>Change <code>tenants/bank-b/</code> → only Bank B's bundles rebuild → UAT → prod</td><td>Days</td><td>Platform team, Bank B</td></tr>
<tr><td>A new feature (for example UPI)</td><td><span class="pill a">backend</span>, <span class="pill">OTA</span>, then <span class="pill a">switch</span></td><td>Bank B's backend upgraded first → feature ships off in a JS train → on for Bank B UAT → Bank B signs off → on for Bank B prod</td><td>After the backend upgrade, the next train and UAT</td><td>Backend and platform teams, Bank B</td></tr>
<tr><td>A new native SDK, app icon or app name</td><td><span class="pill">store</span></td><td>Native train → Bank B UAT build → submit in Bank B's account → staged rollout → raise minimum version</td><td>1–3 weeks</td><td>Platform team, Bank B store owner</td></tr>
</tbody>
</table>
</div>
<div class="prose">
<p><b>What keeps Bank B's change away from other banks</b></p>
<ul>
<li>A change only under <code>tenants/bank-b/</code>: CI detects it and rebuilds only Bank B's bundles.</li>
<li>A change to shared code: every bank gets it in UAT, but each bank's production moves only after its own sign-off.</li>
<li>A backend change or a switch: it happens in Bank B's own backend deployment. Other banks run separate deployments, and Bank B's admins cannot reach them.</li>
<li>On the phone: the channel and the tenant guard stop any other bank's bundle from running.</li>
</ul>
</div>
</section>
<!-- 13 SECURITY -->
<section id="security">
<h2><span class="num">13</span>Security</h2>
<div class="prose">
<ol>
<li><b>The server is the boundary.</b> Every business API checks the bank, the role and the customer again. App-side gating is for the customer's convenience.</li>
<li><b>Each bank's data stays in its own backend:</b> a separate database, key pair and certificate per bank. The central platform holds no customer data.</li>
<li><b>HTTPS with per-bank certificate pinning,</b> for config, security steps and every API. The PIN encryption key comes from the bank's backend, and pinning is what makes that key trustworthy.</li>
<li><b>Fail closed.</b> An unknown feature key, challenge type or unreadable config means off or stop.</li>
<li><b>The bank's off is final.</b> Session and device layers cannot re-enable it; release builds let the device layer switch features off only.</li>
<li><b>Kill switches survive offline,</b> and the cached config only moves forward in version.</li>
<li><b>No leaks between customers or banks:</b> session flags wiped at sign-out, a bundle per bank, the tenant guard, and token-scoped config.</li>
<li><b>Test data never ships.</b> Mock data and test codes exist only in dev builds, and CI checks every release build.</li>
<li><b>Signed code:</b> a store key per bank, signed OTA bundles, production deploys only from CI.</li>
<li><b>Crash reports without personal data,</b> stored where the banks' data rules allow.</li>
<li><b>Every change recorded:</b> two-person approval and an audit log for switches and policies.</li>
</ol>
</div>
</section>
<!-- 14 RISKS -->
<section id="risks">
<h2><span class="num">14</span>Risks</h2>
<div class="tbl">
<table>
<thead><tr><th>Risk</th><th>Why it matters</th><th>How we reduce it</th></tr></thead>
<tbody>
<tr><td>Banks are slow to give store access or sign off</td><td>Releases stall per bank</td><td>Start account setup early; put UAT timelines in the contract</td></tr>
<tr><td>The build matrix grows</td><td>Banks × platforms × environments per release</td><td>Parallel CI, rebuild only affected banks, bundle diffing</td></tr>
<tr><td>Banks drift onto different app versions</td><td>JS must work on several binaries</td><td>Two-version support window, forced update, train discipline</td></tr>
<tr><td>Running many backends</td><td>Patching, monitoring and backups for every bank</td><td>One image, infrastructure scripts, automated deploys, central dashboards, written runbooks</td></tr>
<tr><td>Bank backends drift onto different versions</td><td>The app must work with several backend versions</td><td>Features gated by what each backend reports; at most two backend versions in production; additive APIs only</td></tr>
<tr><td>The OTA server is compromised</td><td>One door into every bank's app</td><td>Signed bundles, CI-only production deploys, audit of every deploy</td></tr>
<tr><td>Too many flags</td><td>Combinations become impossible to test</td><td>Retire leaf flags; snapshot test of each bank's effective config; quarterly catalog review</td></tr>
<tr><td>Too many per-bank overrides</td><td>Overrides drift from the shared screens</td><td>Lowest rung first; at most three overrides per bank; each has an owner</td></tr>
<tr><td>Crash reports carry personal data</td><td>Banking privacy rules</td><td>Scrub before sending; choose storage that meets the banks' data rules</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 15 DECISIONS -->
<section id="decisions">
<h2><span class="num">15</span>Decisions</h2>
<div class="tbl">
<table>
<thead><tr><th>Question</th><th>Recommendation</th><th>Who decides</th></tr></thead>
<tbody>
<tr><td>One backend for all banks, or one per bank?</td><td><span class="pill a">Decided</span> One backend codebase, deployed separately for each bank and environment</td><td>Decided</td></tr>
<tr><td>Where does each bank's backend run?</td><td>In our cloud, one isolated account per bank, in India (RBI's payment-data storage rules), unless a bank requires its own data centre. The image and deploy steps are the same; in a bank's data centre, its operators deploy our signed image</td><td>DevOps, security, banks</td></tr>
<tr><td>Whose store accounts publish each app?</td><td>Each bank's own Google Play and Apple accounts. Apple's guideline 4.2.6 expects the content owner to submit template-based apps</td><td>Product, banks</td></tr>
<tr><td>Adopt the server-driven security protocol?</td><td>Yes; adapters cover older APIs until every backend supports it</td><td>Backend lead, security</td></tr>
<tr><td>Who operates the admin portal?</td><td>Bank staff with two-person approval; our ops for onboarding and emergencies</td><td>Product, banks</td></tr>
<tr><td>Release rhythm</td><td>Weekly JS train, monthly native train, backend releases per bank on sign-off, hotfix any day</td><td>Mobile and backend leads</td></tr>
<tr><td>Where OTA bundles are hosted</td><td>A self-hosted OTA server in our own cloud, with signed bundles</td><td>Security, DevOps</td></tr>
<tr><td>Crash reporting tool</td><td>One that can scrub personal data and store it where the banks' rules allow</td><td>Security</td></tr>
<tr><td>What a restricted customer sees</td><td>Hide what the bank does not offer; explain what the customer can unlock ("Complete KYC to apply")</td><td>Product</td></tr>
</tbody>
</table>
</div>
<div class="prose"><p>The version support window (the last two app versions and at most two backend versions in production) is part of this proposal and needs the banks' agreement.</p></div>
</section>
<footer class="page">
<p>Proposal prepared October 2026. Detailed technical specifications for each section accompany this document.</p>
</footer>
</main>
</div>