docs: add architecture visuals and RAMZ tenant isolation decision

Clio/Stripe competitive benchmark, moslem03/04 backend reality check,
and the moslem04 sync API spec, plus the resolved schema-per-tenant
isolation decision that unblocks Phase 2 of the sync spec.
This commit is contained in:
wmj
2026-08-18 13:12:05 +08:00
parent 6941be23c3
commit 14b34ad532
4 changed files with 986 additions and 0 deletions
@@ -0,0 +1,445 @@
<title>Falah OS — Competitive Benchmark: Clio &amp; Stripe</title>
<style>
:root {
--bg: #f6f5ef;
--bg-panel: #ffffff;
--ink: #1b2420;
--ink-soft: #4d5951;
--line: #dbd9cd;
--line-strong: #b9b6a6;
--accent: #2f6e52;
--accent-soft: #e3ede7;
--steal: #2f6e52;
--steal-bg: #e3ede7;
--build: #a6432e;
--build-bg: #f6e6e1;
--gap: #9a7327;
--gap-bg: #f2e9d4;
--mono-tint: #f0efe6;
}
:root[data-theme="dark"] {
--bg: #141815;
--bg-panel: #1b201c;
--ink: #e9e7dd;
--ink-soft: #a9b0a7;
--line: #333a34;
--line-strong: #48524a;
--accent: #6bbf94;
--accent-soft: #212b24;
--steal: #6bbf94;
--steal-bg: #1d2b22;
--build: #e08268;
--build-bg: #2c1e1a;
--gap: #e3bd6a;
--gap-bg: #2b2416;
--mono-tint: #1f2420;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #141815;
--bg-panel: #1b201c;
--ink: #e9e7dd;
--ink-soft: #a9b0a7;
--line: #333a34;
--line-strong: #48524a;
--accent: #6bbf94;
--accent-soft: #212b24;
--steal: #6bbf94;
--steal-bg: #1d2b22;
--build: #e08268;
--build-bg: #2c1e1a;
--gap: #e3bd6a;
--gap-bg: #2b2416;
--mono-tint: #1f2420;
}
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Source Serif Pro", serif;
}
body {
padding: clamp(1.5rem, 4vw, 4rem);
display: flex;
justify-content: center;
}
main {
width: 100%;
max-width: 920px;
display: flex;
flex-direction: column;
gap: clamp(2.5rem, 5vw, 3.5rem);
}
.masthead {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding-bottom: 1.75rem;
border-bottom: 2px solid var(--ink);
}
.eyebrow {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
h1 {
margin: 0;
font-size: clamp(1.9rem, 4vw, 2.6rem);
font-weight: 600;
letter-spacing: -0.01em;
text-wrap: balance;
}
.dek {
margin: 0;
max-width: 62ch;
font-size: 1.02rem;
line-height: 1.55;
color: var(--ink-soft);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
section.bench {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.bench-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.bench-title {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.bench-title h2 {
margin: 0;
font-size: 1.4rem;
font-weight: 600;
}
.bench-title .vs {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.85rem;
color: var(--ink-soft);
}
.bench-note {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 0.82rem;
color: var(--ink-soft);
max-width: 40ch;
text-align: right;
line-height: 1.4;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--line-strong);
border-radius: 3px;
background: var(--bg-panel);
}
table {
width: 100%;
min-width: 640px;
border-collapse: collapse;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 0.88rem;
}
thead th {
text-align: left;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-soft);
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--line-strong);
background: var(--mono-tint);
}
tbody td {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
line-height: 1.45;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
td.area {
font-weight: 600;
white-space: nowrap;
color: var(--ink);
}
td.detail { color: var(--ink-soft); }
td.verdict { width: 15%; }
.chip {
display: inline-block;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.22rem 0.55rem;
border-radius: 3px;
white-space: nowrap;
}
.chip.steal { color: var(--steal); background: var(--steal-bg); }
.chip.build { color: var(--build); background: var(--build-bg); }
.chip.gap { color: var(--gap); background: var(--gap-bg); }
.legend {
display: flex;
gap: 1.4rem;
flex-wrap: wrap;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 0.78rem;
color: var(--ink-soft);
align-items: center;
}
.legend span { display: inline-flex; align-items: center; gap: 0.4rem; }
.synth {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
padding: 1.5rem;
border: 1px solid var(--line-strong);
border-radius: 3px;
background: var(--bg-panel);
}
@media (max-width: 640px) {
.synth { grid-template-columns: 1fr; }
}
.synth-col h3 {
margin: 0 0 0.85rem 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 0.5rem;
}
.synth-col.steal h3 { color: var(--steal); }
.synth-col.build h3 { color: var(--build); }
.synth ol {
margin: 0;
padding: 0 0 0 1.15rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 0.86rem;
line-height: 1.55;
color: var(--ink);
}
.synth ol li::marker {
font-family: ui-monospace, monospace;
color: var(--ink-soft);
}
.synth li + li { margin-top: 0.4rem; }
.divider {
grid-column: 1 / -1;
height: 1px;
background: var(--line);
display: none;
}
@media (max-width: 640px) {
.divider { display: block; }
}
footer {
padding-top: 1.5rem;
border-top: 1px solid var(--line);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 0.78rem;
color: var(--ink-soft);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
footer strong { color: var(--ink); }
</style>
<main>
<div class="masthead">
<span class="eyebrow">Falah OS — Nur Falah Admin Console</span>
<h1>Competitive benchmark: Clio &amp; Stripe Dashboard</h1>
<p class="dek">Two reference products mapped against EstateOS and the cross-tenant super-admin surface. Every row ends in a call: <strong style="color:var(--steal)">steal</strong> the pattern, <strong style="color:var(--build)">build</strong> it ourselves, or note it as a <strong style="color:var(--gap)">structural gap</strong> the reference product simply doesn't resolve.</p>
</div>
<section class="bench">
<div class="bench-head">
<div class="bench-title">
<h2>Clio</h2>
<span class="vs">vs. EstateOS</span>
</div>
<p class="bench-note">Legal practice management — case files, billing, client portal, co-counsel collaboration.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Feature area</th><th>What Clio does</th><th>Verdict for EstateOS</th></tr>
</thead>
<tbody>
<tr>
<td class="area">Intake → billing</td>
<td class="detail">Clio Grow captures leads via custom forms, hands off to Clio Manage automatically once a matter opens.</td>
<td class="verdict"><span class="chip steal">Steal</span></td>
</tr>
<tr>
<td class="area">Document automation</td>
<td class="detail">Template library with variable-filled document generation, version control.</td>
<td class="verdict"><span class="chip steal">Steal</span></td>
</tr>
<tr>
<td class="area">Billing &amp; invoicing</td>
<td class="detail">Timers, hourly/contingency billing, AI-drafted invoices with error flagging.</td>
<td class="verdict"><span class="chip steal">Steal</span></td>
</tr>
<tr>
<td class="area">Trust accounting</td>
<td class="detail">Client trust ledgers, deposit/disbursement tracking, compliance reporting.</td>
<td class="verdict"><span class="chip steal">Steal</span></td>
</tr>
<tr>
<td class="area">Client portal</td>
<td class="detail">Encrypted messaging, document sharing, payments — but single-brand, one firm per instance.</td>
<td class="verdict"><span class="chip steal">Steal UX</span></td>
</tr>
<tr>
<td class="area">Faraid / Syariah workflows</td>
<td class="detail">No inheritance-share calculation, no Syariah Court filing templates — generic common-law matter types only.</td>
<td class="verdict"><span class="chip build">Build</span></td>
</tr>
<tr>
<td class="area">Panel-lawyer vetting</td>
<td class="detail">"Clio for Co-Counsel" assumes counsel is already chosen — it's a collaboration portal, not a credentialing tool.</td>
<td class="verdict"><span class="chip build">Build</span></td>
</tr>
<tr>
<td class="area">Per-tenant branding</td>
<td class="detail">Client portal is single-brand per firm — no white-label capability for a platform serving multiple firms.</td>
<td class="verdict"><span class="chip build">Build</span></td>
</tr>
<tr>
<td class="area">Multi-tenant backend</td>
<td class="detail">Clio is one firm, one instance — no shared-backend, multi-firm architecture exists to compare against.</td>
<td class="verdict"><span class="chip gap">Structural gap</span></td>
</tr>
<tr>
<td class="area">RAMZ row-level access</td>
<td class="detail">Firm-scoped permissions throughout — no concept of row-level, cross-tenant gating.</td>
<td class="verdict"><span class="chip gap">Structural gap</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="bench">
<div class="bench-head">
<div class="bench-title">
<h2>Stripe Dashboard</h2>
<span class="vs">vs. Falah OS super-admin</span>
</div>
<p class="bench-note">Connect platform admin — connected-account management, roles, audit, API access.</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>Feature area</th><th>What Stripe does</th><th>Verdict for Falah OS</th></tr>
</thead>
<tbody>
<tr>
<td class="area">Admin role tiers</td>
<td class="detail">Admin vs. Super Administrator — Super Admin gates who can grant/revoke roles and manage environments.</td>
<td class="verdict"><span class="chip steal">Steal</span></td>
</tr>
<tr>
<td class="area">Audit logging</td>
<td class="detail">Activity Logs API — exportable, SOC 2/PCI-ready record of key changes, invitations, role changes.</td>
<td class="verdict"><span class="chip steal">Steal</span></td>
</tr>
<tr>
<td class="area">API key scoping</td>
<td class="detail">Restricted keys limited to specific permission sets per integration.</td>
<td class="verdict"><span class="chip steal">Steal</span></td>
</tr>
<tr>
<td class="area">Tenant status view</td>
<td class="detail">Connected-account list with onboarding and risk status at a glance.</td>
<td class="verdict"><span class="chip steal">Steal UX</span></td>
</tr>
<tr>
<td class="area">Onboarding flow</td>
<td class="detail">Hosted or embedded onboarding, incremental — collects only what's currently required.</td>
<td class="verdict"><span class="chip steal">Steal</span></td>
</tr>
<tr>
<td class="area">Cross-regime compliance routing</td>
<td class="detail">No concept of routing tenants through different regulatory rule sets (Syariah Court vs. JKPTG vs. govt case routing).</td>
<td class="verdict"><span class="chip build">Build</span></td>
</tr>
<tr>
<td class="area">Usage / billing-trigger events</td>
<td class="detail">Activity Logs are security-focused, not usage-metering. Stripe's actual metering product wasn't covered — separate follow-up needed.</td>
<td class="verdict"><span class="chip build">Build / unclear</span></td>
</tr>
<tr>
<td class="area">Tenant = product line</td>
<td class="detail">Every connected account is a roughly uniform merchant entity — no support for managing distinct SaaS products per tenant.</td>
<td class="verdict"><span class="chip gap">Structural gap</span></td>
</tr>
<tr>
<td class="area">RAMZ row-level access</td>
<td class="detail">Isolation happens at the account level (own object graph per tenant) — closer to separate-DB than row-level gating.</td>
<td class="verdict"><span class="chip gap">Structural gap</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="legend">
<span><span class="chip steal">Steal</span> pattern transfers directly</span>
<span><span class="chip build">Build</span> reference has nothing comparable</span>
<span><span class="chip gap">Structural gap</span> reference's whole model doesn't apply</span>
</div>
<section class="synth">
<div class="synth-col steal">
<h3>Combined — steal list</h3>
<ol>
<li>Intake → billing handoff (Clio Grow → Manage)</li>
<li>Document automation + template library</li>
<li>Trust ledger compliance reporting</li>
<li>Two-tier Admin / Super Admin roles</li>
<li>Exportable audit log, scoped API keys</li>
</ol>
</div>
<div class="divider"></div>
<div class="synth-col build">
<h3>Combined — build list</h3>
<ol>
<li>Faraid inheritance calculation engine</li>
<li>Panel-lawyer vetting / credentialing</li>
<li>Per-tenant white-labeled portal branding</li>
<li>RAMZ row-level, shared-schema isolation</li>
<li>Cross-regime compliance routing (Syariah / JKPTG / govt)</li>
</ol>
</div>
</section>
<footer>
<span>Sources: clio.com, docs.stripe.com, support.stripe.com — full citations in the prior research turns.</span>
<span><strong>Open:</strong> Core Ledger billing-event readiness still unconfirmed against Stripe's metering product.</span>
</footer>
</main>
@@ -0,0 +1,259 @@
<title>Falah OS — Backend Integration Reality Check</title>
<style>
:root {
--bg: #f6f5ef;
--bg-panel: #ffffff;
--ink: #1b2420;
--ink-soft: #4d5951;
--line: #dbd9cd;
--line-strong: #b9b6a6;
--accent: #2f6e52;
--accent-soft: #e3ede7;
--warn: #a6432e;
--warn-bg: #f6e6e1;
--future: #9a7327;
--future-bg: #f2e9d4;
--node-bg: #ffffff;
}
:root[data-theme="dark"] {
--bg: #141815;
--bg-panel: #1b201c;
--ink: #e9e7dd;
--ink-soft: #a9b0a7;
--line: #333a34;
--line-strong: #48524a;
--accent: #6bbf94;
--accent-soft: #212b24;
--warn: #e08268;
--warn-bg: #2c1e1a;
--future: #e3bd6a;
--future-bg: #2b2416;
--node-bg: #1f2420;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #141815;
--bg-panel: #1b201c;
--ink: #e9e7dd;
--ink-soft: #a9b0a7;
--line: #333a34;
--line-strong: #48524a;
--accent: #6bbf94;
--accent-soft: #212b24;
--warn: #e08268;
--warn-bg: #2c1e1a;
--future: #e3bd6a;
--future-bg: #2b2416;
--node-bg: #1f2420;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
body { padding: clamp(1.25rem, 4vw, 3rem); display: flex; justify-content: center; }
main { width: 100%; max-width: 900px; display: flex; flex-direction: column; gap: 2.5rem; }
.masthead { display: flex; flex-direction: column; gap: 0.5rem; padding-bottom: 1.25rem; border-bottom: 2px solid var(--ink); }
.eyebrow { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.7rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); }
h1 { margin: 0; font-size: clamp(1.5rem, 3.4vw, 2rem); font-weight: 700; letter-spacing: -0.01em; text-wrap: balance; }
.dek { margin: 0; max-width: 60ch; font-size: 0.98rem; line-height: 1.5; color: var(--ink-soft); }
h2.section-title {
display: flex; align-items: center; gap: 0.6rem;
font-size: 0.75rem; letter-spacing: 0.1em; text-transform: uppercase;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--ink-soft); margin: 0 0 1.1rem 0;
}
h2.section-title .n { color: var(--ink); font-weight: 700; }
/* ---- Diagram 1: today ---- */
.diagram {
border: 1px solid var(--line-strong);
border-radius: 6px;
background: var(--bg-panel);
padding: clamp(1.25rem, 3vw, 2rem);
overflow-x: auto;
}
.row { display: flex; align-items: center; gap: 0; min-width: 640px; }
.node {
flex: 1;
border: 1.5px solid var(--line-strong);
border-radius: 6px;
background: var(--node-bg);
padding: 0.9rem 1rem;
text-align: center;
display: flex; flex-direction: column; gap: 0.25rem;
}
.node .label { font-weight: 700; font-size: 0.92rem; }
.node .sub { font-size: 0.74rem; color: var(--ink-soft); }
.arrow {
flex: 0 0 60px;
display: flex; align-items: center; justify-content: center;
font-family: ui-monospace, monospace;
color: var(--ink-soft);
font-size: 1.1rem;
}
.node.app { border-color: var(--accent); background: var(--accent-soft); }
.node.api { border-style: dashed; }
.node.void { border-color: var(--warn); background: var(--warn-bg); border-style: dashed; color: var(--warn); }
.node.void .label { color: var(--warn); }
.three-up { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem; margin-top: 1rem; min-width: 640px; }
.three-up .node .sub { line-height: 1.35; }
.badge-row { display: flex; gap: 0.6rem; margin-top: 1.1rem; flex-wrap: wrap; }
.badge {
font-family: ui-monospace, monospace; font-size: 0.68rem; font-weight: 700;
letter-spacing: 0.04em; text-transform: uppercase;
padding: 0.25rem 0.6rem; border-radius: 3px;
}
.badge.no { color: var(--warn); background: var(--warn-bg); }
/* ---- Diagram 2: what needs building ---- */
.stack { display: flex; flex-direction: column; gap: 0; min-width: 560px; }
.layer {
display: grid;
grid-template-columns: 30px 1fr;
border: 1.5px solid var(--line-strong);
border-top: none;
background: var(--node-bg);
}
.layer:first-child { border-top: 1.5px solid var(--line-strong); border-radius: 6px 6px 0 0; overflow: hidden; }
.layer:last-child { border-radius: 0 0 6px 6px; overflow: hidden; }
.layer-num {
display: flex; align-items: center; justify-content: center;
font-family: ui-monospace, monospace; font-size: 0.78rem; font-weight: 700;
color: var(--ink-soft); background: color-mix(in srgb, var(--ink) 4%, transparent);
border-right: 1px solid var(--line);
}
.layer-body { padding: 0.85rem 1.1rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.layer-body .title { font-weight: 700; font-size: 0.92rem; }
.layer-body .desc { font-size: 0.8rem; color: var(--ink-soft); }
.layer.exists { }
.layer.exists .layer-num { color: var(--accent); }
.layer.missing .layer-num { color: var(--future); }
.status {
font-family: ui-monospace, monospace; font-size: 0.66rem; font-weight: 700;
letter-spacing: 0.05em; text-transform: uppercase; padding: 0.18rem 0.5rem; border-radius: 3px;
white-space: nowrap;
}
.status.exists { color: var(--accent); background: var(--accent-soft); }
.status.missing { color: var(--future); background: var(--future-bg); }
.legend { display: flex; gap: 1.3rem; flex-wrap: wrap; font-size: 0.78rem; color: var(--ink-soft); margin-top: 1rem; }
.legend span { display: inline-flex; align-items: center; gap: 0.4rem; }
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.dot.exists { background: var(--accent); }
.dot.missing { background: var(--future); }
footer { padding-top: 1.25rem; border-top: 1px solid var(--line); font-size: 0.78rem; color: var(--ink-soft); }
footer strong { color: var(--ink); }
</style>
<main>
<div class="masthead">
<span class="eyebrow">Falah OS — checked against the actual repos</span>
<h1>moslem03 has no backend either — same gap as moslem04</h1>
<p class="dek">Verified against <code>nur-muslim-companion-v2</code> and <code>nur-falah-prevention</code> source. Neither app talks to a Falah OS server. There is no existing pattern to copy — the admin console link has to be built from zero.</p>
</div>
<section>
<h2 class="section-title"><span class="n">01</span> · What moslem03 actually does today</h2>
<div class="diagram">
<div class="row">
<div class="node app">
<span class="label">moslem03.falahos.my</span>
<span class="sub">Svelte 5 PWA · static build</span>
</div>
<div class="arrow"></div>
<div class="node api">
<span class="label">Public third-party APIs</span>
<span class="sub">Al Quran Cloud · Aladhan · Overpass</span>
</div>
</div>
<div class="three-up">
<div class="node void">
<span class="label">✕ No auth</span>
<span class="sub">Anyone opens the app, no login</span>
</div>
<div class="node void">
<span class="label">✕ No Falah OS server</span>
<span class="sub">Never calls anything on the Contabo VPS</span>
</div>
<div class="node void">
<span class="label">✕ No admin console link</span>
<span class="sub">Nothing to sync — data never leaves the browser</span>
</div>
</div>
<div class="badge-row">
<span class="badge no">Same for moslem04</span>
<span class="badge no">Same localStorage-only pattern</span>
</div>
</div>
</section>
<section>
<h2 class="section-title"><span class="n">02</span> · What has to be built — bottom up</h2>
<div class="diagram">
<div class="stack">
<div class="layer exists">
<div class="layer-num"></div>
<div class="layer-body">
<div>
<div class="title">casdoor (ummahid) identity service</div>
<div class="desc">Already running on the Contabo VPS :8000 — reuse it, don't rebuild login</div>
</div>
<span class="status exists">Already exists</span>
</div>
</div>
<div class="layer missing">
<div class="layer-num">1</div>
<div class="layer-body">
<div>
<div class="title">Login screen in moslem04</div>
<div class="desc">App currently has zero auth UI — needs a casdoor sign-in flow bolted on</div>
</div>
<span class="status missing">To build</span>
</div>
</div>
<div class="layer missing">
<div class="layer-num">2</div>
<div class="layer-body">
<div>
<div class="title">Sync API (new)</div>
<div class="desc">Push Faraid / Assets / Wassiyah / Hibah / Waqf drafts from localStorage to a server, encrypted</div>
</div>
<span class="status missing">To build</span>
</div>
</div>
<div class="layer missing">
<div class="layer-num">3</div>
<div class="layer-body">
<div>
<div class="title">RAMZ tenant tagging</div>
<div class="desc">Every synced record gets stamped with which tenant/firm it belongs to</div>
</div>
<span class="status missing">To build</span>
</div>
</div>
<div class="layer missing">
<div class="layer-num">4</div>
<div class="layer-body">
<div>
<div class="title">Admin console read view</div>
<div class="desc">Nur Falah Admin Console reads synced cases — view only, not edit</div>
</div>
<span class="status missing">To build</span>
</div>
</div>
</div>
<div class="legend">
<span><span class="dot exists"></span> exists on the VPS today</span>
<span><span class="dot missing"></span> needs to be built — nothing to copy from moslem03</span>
</div>
</div>
</section>
<footer>
<strong>Bottom line:</strong> moslem03 gave us one reusable piece — casdoor login. Everything else (sync, tenant tagging, admin visibility) starts from scratch.
</footer>
</main>
@@ -0,0 +1,58 @@
# RAMZ Tenant Isolation — Decision
**Status:** Resolved
**Blocks:** Phase 2 of the [moslem04 Sync API spec](./sync-api-spec.html) (admin console read view)
**Originally flagged in:** Nur Falah Admin Console PRD review
## The question
Falah OS runs one shared backend serving three tenants — EstateOS (law firm estate/probate,
Syariah Court), Tanah Hidup (waqf land lease, JKPTG), and Cair (govt case routing). How is
each tenant's data isolated from the others?
Two options were on the table:
1. **Separate database per tenant** — strongest isolation, highest ops overhead.
2. **Row-level RAMZ-gated access in a shared schema** — cheapest, weakest isolation story.
## Decision
**Schema-per-tenant on a single shared Postgres cluster.**
Each tenant (EstateOS, Tanah Hidup, Cair) gets its own Postgres schema. Not a separate
server, not a shared table filtered by a `tenant_id` column.
## Why
- The three tenants aren't multiple firms sharing one product (the Clio model) — they're
three structurally different products, each answering to a different regulator
(Syariah Court, JKPTG, government agencies). A `WHERE tenant_id = ?` filter on a shared
table is not an answer a regulator's auditor will accept at face value; a schema
boundary is something that can actually be demonstrated.
- This was flagged as a trust/compliance blocker for the UK Innovator Visa pitch — the
isolation model isn't just an engineering choice, it's part of the pitch.
- Fully separate databases (or separate servers) solve the same trust problem but multiply
ops burden — separate backup policies, connection pools, migrations to run three times
instead of once. Not justified at current scale.
- Schema-per-tenant keeps one cluster, one backup policy, one connection pool — while still
giving each tenant a real, auditable namespace boundary.
## What RAMZ still does
RAMZ doesn't disappear — it moves down a level. Within each tenant's schema, RAMZ handles
the finer-grained access control that a schema boundary can't: which user or law firm
within EstateOS can see which case, which land officer within Tanah Hidup can see which
lease. RAMZ was never going to be strong enough to be the *only* thing standing between
tenants — now it doesn't have to be.
## Consequence for the sync API
The `tenant_id` field in the [sync spec](./sync-api-spec.html) becomes a schema selector,
not a row filter. The admin console's `/v1/admin/metadata` endpoint queries across schemas
explicitly, per-tenant — there is no accidental cross-tenant query path.
## Still open
This resolves the isolation *model*. It does not resolve whether Core Ledger already emits
the billing-trigger events Phase 2 needs, or who owns panel-lawyer vetting — both remain
open from the earlier PRD review.
+224
View File
@@ -0,0 +1,224 @@
<title>moslem04 Sync API — Architecture Spec</title>
<style>
:root {
--bg: #f6f5ef;
--bg-panel: #ffffff;
--ink: #1b2420;
--ink-soft: #4d5951;
--line: #dbd9cd;
--line-strong: #b9b6a6;
--accent: #2f6e52;
--accent-soft: #e3ede7;
--warn: #a6432e;
--warn-bg: #f6e6e1;
--future: #9a7327;
--future-bg: #f2e9d4;
--mono-tint: #f0efe6;
--node-bg: #ffffff;
}
:root[data-theme="dark"] {
--bg: #141815; --bg-panel: #1b201c; --ink: #e9e7dd; --ink-soft: #a9b0a7;
--line: #333a34; --line-strong: #48524a; --accent: #6bbf94; --accent-soft: #212b24;
--warn: #e08268; --warn-bg: #2c1e1a; --future: #e3bd6a; --future-bg: #2b2416;
--mono-tint: #1f2420; --node-bg: #1f2420;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #141815; --bg-panel: #1b201c; --ink: #e9e7dd; --ink-soft: #a9b0a7;
--line: #333a34; --line-strong: #48524a; --accent: #6bbf94; --accent-soft: #212b24;
--warn: #e08268; --warn-bg: #2c1e1a; --future: #e3bd6a; --future-bg: #2b2416;
--mono-tint: #1f2420; --node-bg: #1f2420;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
body { padding: clamp(1.25rem, 4vw, 3rem); display: flex; justify-content: center; }
main { width: 100%; max-width: 940px; display: flex; flex-direction: column; gap: 2.75rem; }
code, .mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
/* masthead */
.masthead { display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 1.4rem; border-bottom: 2px solid var(--ink); }
.eyebrow { font-size: 0.7rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); font-family: ui-monospace, monospace; }
h1 { margin: 0; font-size: clamp(1.6rem, 3.6vw, 2.2rem); font-weight: 700; letter-spacing: -0.01em; text-wrap: balance; }
.dek { margin: 0; max-width: 68ch; font-size: 0.98rem; line-height: 1.55; color: var(--ink-soft); }
.scope-note {
margin-top: 0.4rem; display: flex; gap: 0.6rem; align-items: flex-start;
font-size: 0.85rem; padding: 0.7rem 0.9rem; background: var(--future-bg); border-radius: 4px;
border-left: 3px solid var(--future); color: var(--ink);
}
.scope-note strong { color: var(--future); }
h2.section-title {
display: flex; align-items: baseline; gap: 0.6rem;
font-size: 0.75rem; letter-spacing: 0.1em; text-transform: uppercase;
font-family: ui-monospace, monospace; color: var(--ink-soft); margin: 0 0 1.1rem 0;
}
h2.section-title .n { color: var(--accent); font-weight: 700; }
h2.section-title .t { color: var(--ink); font-size: 0.78rem; letter-spacing: 0.08em; }
p.body-text { font-size: 0.92rem; line-height: 1.6; color: var(--ink); max-width: 68ch; margin: 0 0 0.9rem 0; }
p.body-text:last-child { margin-bottom: 0; }
/* flow diagram */
.diagram { border: 1px solid var(--line-strong); border-radius: 6px; background: var(--bg-panel); padding: clamp(1.1rem, 3vw, 1.6rem); overflow-x: auto; }
.flow { display: flex; align-items: stretch; gap: 0; min-width: 760px; }
.fnode {
flex: 1; border: 1.5px solid var(--line-strong); border-radius: 6px; background: var(--node-bg);
padding: 0.85rem 0.9rem; display: flex; flex-direction: column; gap: 0.3rem; justify-content: center;
}
.fnode .label { font-weight: 700; font-size: 0.85rem; }
.fnode .sub { font-size: 0.72rem; color: var(--ink-soft); line-height: 1.35; }
.fnode.client { border-color: var(--accent); background: var(--accent-soft); }
.fnode.new { border-style: dashed; border-color: var(--future); }
.fnode.new .label { color: var(--future); }
.fnode.gated { border-color: var(--warn); background: var(--warn-bg); }
.fnode.gated .label { color: var(--warn); }
.farrow { flex: 0 0 34px; display: flex; align-items: center; justify-content: center; color: var(--ink-soft); font-size: 1.1rem; }
/* tables */
.table-wrap { overflow-x: auto; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--bg-panel); }
table { width: 100%; min-width: 600px; border-collapse: collapse; font-size: 0.85rem; }
thead th {
text-align: left; font-family: ui-monospace, monospace; font-size: 0.66rem; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--ink-soft); padding: 0.65rem 0.85rem; border-bottom: 1px solid var(--line-strong);
background: var(--mono-tint);
}
tbody td { padding: 0.7rem 0.85rem; border-bottom: 1px solid var(--line); vertical-align: top; line-height: 1.45; }
tbody tr:last-child td { border-bottom: none; }
td.method { font-family: ui-monospace, monospace; font-weight: 700; font-size: 0.76rem; white-space: nowrap; }
td.path { font-family: ui-monospace, monospace; font-size: 0.8rem; white-space: nowrap; color: var(--ink); }
td.detail { color: var(--ink-soft); }
.m-get { color: var(--accent); } .m-post { color: #4d6fb3; } .m-delete { color: var(--warn); }
:root[data-theme="dark"] .m-post, .m-post { color: #7fa2e6; }
.chip { display: inline-block; font-family: ui-monospace, monospace; font-size: 0.64rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.18rem 0.5rem; border-radius: 3px; white-space: nowrap; }
.chip.locked { color: var(--warn); background: var(--warn-bg); }
.chip.opt { color: var(--future); background: var(--future-bg); }
.chip.never { color: var(--ink-soft); background: var(--mono-tint); }
/* rollout */
.phases { display: flex; gap: 0; min-width: 700px; }
.phase {
flex: 1; border: 1.5px solid var(--line-strong); background: var(--node-bg); padding: 1rem;
display: flex; flex-direction: column; gap: 0.4rem; position: relative;
}
.phase:not(:first-child) { border-left: none; }
.phase:first-child { border-radius: 6px 0 0 6px; }
.phase:last-child { border-radius: 0 6px 6px 0; }
.phase .ph-label { font-family: ui-monospace, monospace; font-size: 0.66rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent); }
.phase .ph-title { font-weight: 700; font-size: 0.88rem; }
.phase .ph-desc { font-size: 0.78rem; color: var(--ink-soft); line-height: 1.4; }
.phase.blocked { background: var(--warn-bg); }
.phase.blocked .ph-label { color: var(--warn); }
footer { padding-top: 1.4rem; border-top: 1px solid var(--line); font-size: 0.8rem; color: var(--ink-soft); line-height: 1.6; }
footer strong { color: var(--ink); }
</style>
<main>
<div class="masthead">
<span class="eyebrow">Falah OS — Nur Falah Estate &amp; Waqf Suite</span>
<h1>Sync API architecture — moslem04.falahos.my</h1>
<p class="dek">A server-side sync layer for moslem04's six local-first modules, gated by casdoor login, encrypted client-side before it ever leaves the device, and surfacing only opt-in metadata to the admin console — never raw estate documents.</p>
<div class="scope-note"><strong>Out of scope:</strong> moslem03 (Muslim Companion) stays exactly as-is — free, zero signup, no account, no sync, forever. This spec touches nothing there.</div>
</div>
<section>
<h2 class="section-title"><span class="n">01</span><span class="t">Data flow, end to end</span></h2>
<div class="diagram">
<div class="flow">
<div class="fnode client">
<span class="label">moslem04 (browser)</span>
<span class="sub">localStorage stays the source of truth offline; sync is additive, not a replacement</span>
</div>
<div class="farrow"></div>
<div class="fnode">
<span class="label">casdoor (existing)</span>
<span class="sub">OIDC login, issues JWT — nothing new to build here</span>
</div>
<div class="farrow"></div>
<div class="fnode new">
<span class="label">Sync API (new)</span>
<span class="sub">Accepts encrypted blobs, tags with tenant + user, never decrypts</span>
</div>
<div class="farrow"></div>
<div class="fnode new">
<span class="label">Postgres (new)</span>
<span class="sub">Ciphertext + RAMZ tenant column + a small plaintext metadata row</span>
</div>
<div class="farrow"></div>
<div class="fnode gated">
<span class="label">Admin console</span>
<span class="sub">Reads metadata only — opt-in, read-only, per Phase 2 below</span>
</div>
</div>
</div>
</section>
<section>
<h2 class="section-title"><span class="n">02</span><span class="t">What gets encrypted vs. what admin can see</span></h2>
<p class="body-text">Estate plans are the most sensitive data a person will put into this app. The sync API is built so the server — and by extension the admin console — physically cannot read the contents of anyone's Faraid, Wassiyah, Hibah, or Waqf drafts. Encryption happens on-device before upload.</p>
<div class="table-wrap">
<table>
<thead><tr><th>Field</th><th>Who can read it</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="mono">payload_ciphertext</td><td><span class="chip locked">User only</span></td><td class="detail">AES-GCM, key derived from casdoor session + on-device passphrase. Server stores bytes it cannot open.</td></tr>
<tr><td class="mono">module</td><td><span class="chip opt">User + admin</span></td><td class="detail">Which of the 6 modules the record belongs to — plaintext, needed for routing and counts only.</td></tr>
<tr><td class="mono">plan_completed</td><td><span class="chip opt">User + admin</span></td><td class="detail">Boolean per module. Lets the console show "how many people finished their Wassiyah" without seeing a single will.</td></tr>
<tr><td class="mono">panel_lawyer_requested</td><td><span class="chip opt">User + admin</span></td><td class="detail">Explicit opt-in flag the user sets — the only trigger that surfaces a person to a panel lawyer at all.</td></tr>
<tr><td class="mono">tenant_id (RAMZ)</td><td><span class="chip opt">Admin only</span></td><td class="detail">Which tenant/firm this record is scoped to — still pending the row-level isolation decision from the PRD review.</td></tr>
<tr><td class="mono">claims_payload (H2)</td><td><span class="chip never">Nobody, not synced</span></td><td class="detail">Horizon 2 Claims module is excluded from sync entirely until the Phase 0 legal gate in DEPLOY.md clears.</td></tr>
</tbody>
</table>
</div>
</section>
<section>
<h2 class="section-title"><span class="n">03</span><span class="t">Endpoints</span></h2>
<div class="table-wrap">
<table>
<thead><tr><th>Method</th><th>Path</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td class="method m-post">POST</td><td class="path">/v1/auth/exchange</td><td class="detail">Trade a casdoor OIDC token for a moslem04 session token</td></tr>
<tr><td class="method m-get">GET</td><td class="path">/v1/sync/pull?since=&lt;ts&gt;</td><td class="detail">Fetch records changed since last sync — for restoring on a new device</td></tr>
<tr><td class="method m-post">POST</td><td class="path">/v1/sync/push</td><td class="detail">Batch upsert of encrypted blobs from the local device queue</td></tr>
<tr><td class="method m-delete">DELETE</td><td class="path">/v1/sync/:record_id</td><td class="detail">Tombstone a record — deletes propagate across the user's own devices</td></tr>
<tr><td class="method m-post">POST</td><td class="path">/v1/consent/panel-lawyer</td><td class="detail">The one explicit opt-in action that makes a user visible to EstateOS admin at all</td></tr>
<tr><td class="method m-get">GET</td><td class="path">/v1/admin/metadata</td><td class="detail">Admin console read — returns only the plaintext metadata fields from §02, RAMZ-scoped</td></tr>
</tbody>
</table>
</div>
</section>
<section>
<h2 class="section-title"><span class="n">04</span><span class="t">Conflict handling</span></h2>
<p class="body-text">These are single-owner personal drafts, not shared documents — so last-write-wins by <code>updated_at</code> is sufficient for five of the six modules. Two exceptions: <strong>Family Waqf Designator</strong> can involve co-designators, so pushes there append to a change log instead of overwriting; <strong>Digital Claims (H2)</strong> doesn't sync at all, see §02.</p>
</section>
<section>
<h2 class="section-title"><span class="n">05</span><span class="t">Rollout phases</span></h2>
<div class="diagram">
<div class="phases">
<div class="phase">
<span class="ph-label">Phase 0</span>
<span class="ph-title">Auth + sync plumbing</span>
<span class="ph-desc">Login, encrypted push/pull, cross-device restore. No admin visibility of any kind.</span>
</div>
<div class="phase">
<span class="ph-label">Phase 1</span>
<span class="ph-title">Opt-in metadata surface</span>
<span class="ph-desc">Panel-lawyer request flag goes live. Admin sees only what §02 allows.</span>
</div>
<div class="phase blocked">
<span class="ph-label">Phase 2 · blocked</span>
<span class="ph-title">Full admin console read view</span>
<span class="ph-desc">Waits on the RAMZ isolation decision and Core Ledger billing-event readiness — both still open from the earlier PRD review.</span>
</div>
</div>
</div>
</section>
<footer>
<strong>Carried-over open questions this spec does not resolve:</strong> row-level (RAMZ) vs. separate-DB tenant isolation, whether Core Ledger already emits the billing-trigger events Phase 2 would need, and who owns panel-lawyer vetting once requests start arriving. All three block Phase 2, not Phase 01.
</footer>
</main>