Files
nur-falah-prevention/docs/architecture/sync-api-spec.html
T
wmj 14b34ad532 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.
2026-08-18 13:12:05 +08:00

225 lines
14 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>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>