# Microservices Roadmap (monolith → modular → services) How the stdlib monolith is factored into bounded contexts, and the spikes and gates on the road to services. - Page: - Updated: 2026-10-13 - Verified against: `a661527` on 2026-09-26 - From the Sri Guru Granth Sahib Ji — Knowledge Base wiki. Sri Guru Granth Sahib Ji is quoted verbatim, cited by Ang, and never altered. --- `webapp/serve.py` is a single stdlib module today. The path to services is **incremental and proven by the golden vectors** — no behaviour changes as seams are extracted. ## Step 1 — modular monolith (in-repo package `webapp/sggs/`) Split the single dispatch ladder into modules behind an app-context object: `db.py` (connection factory, `_int`, `rows_to_list`, degradation convention), `search/` (waterfall + `romannorm`), `reader.py`, `analytics.py`, `timing.py`, `verify.py`, `static.py`, `http.py` (router). `serve.py` becomes a thin entrypoint. Extraction order by coupling (cleanest first): verify, then timing (today's knowledge context), analytics (today's insights), search, and reader last. **Progress.** 2026-09-24 — `api()` is a route table (`serve.ROUTES`): 26 handlers, each tagged with its bounded context (reader, search, verify, insights, knowledge). Behaviour proven identical (golden vectors in-process and over HTTP, a differential old-vs-new run, all three harnesses). 2026-09-24 — the handlers and their helpers live in bounded-context modules under `webapp/sggs/` (`core` · `search` · `reader` · `verification` · `insights` · `knowledge`). `core` owns the one DB handle and all shared mutable state; context modules reach it as `core.NAME` and never import `serve`. `serve.py` is the composition root (build identity, route table, HTTP layer) and proxies `serve.DB = …` to `core`. Behaviour proven identical again (80/80 differential, seeded Hukam, HTTP contract against `python serve.py`, harnesses). 2026-09-24 — each context declares the tables it reads (`TABLES`; `serve.CONTEXT_TABLES`), enforced by an authorizer-scoped test over every golden suite and OpenAPI sample. Finding: no API route reads `fts_tri`. 2026-09-24 — `SGGS_MODULES` runs any subset of contexts from one image (default `all` = the monolith); `SGGS_DB` selects a database slice; split mode refuses to start on a slice missing a declared table; `/healthz` + `/readyz` (the Docker HEALTHCHECK now uses `/readyz`). Phase 2 (modular monolith) complete. ## Step 2 — service split (later) Each module becomes a service behind a gateway that keeps the **single `/api/*` origin** (so no CORS is introduced), each with a shared read-only DB image. The `contract/*.ndjson` vectors become cross-service tests (`tools/contract_http.py` replays them over HTTP against any running API); `contract/openapi.json` (generated by `tools/gen_openapi.py`) is the contract. Verify and Timing extract first (own tables, own graceful-degradation path). Guardrails: `/api/meta` gains a per-module health block; every response stays same-origin; golden vectors + `webapp/tests/` prove byte-identical behaviour before and after each move. ## Spike S3 — database slices (measured 2026-09-24, dataset `cb6775ff…`, full DB 108.4 MB) `tools/slice_db.py --modules --out build/slices/.sqlite` (or `make slices`) cuts a service's database from the pinned one: the context's declared tables plus their closure (foreign-key targets, FTS5 shadow tables and external content tables), VACUUMed, every kept table proven equal in content to the full database and every kept FTS index passing `integrity-check`, installed atomically. Each slice was then served on its own (`SGGS_MODULES= SGGS_DB=`): | Service | Tables | Slice | Ready (`/readyz`) | Key routes | |---|---|---|---|---| | search | 24 | 74.0 MB (68 %) | 90 ms | `/api/search` 10–200 ms, `/api/word` 8 ms | | insights | 17 | 65.7 MB (61 %) | 91 ms | `/api/themes/network` 6 ms, `/api/analytics/*` ≤ 2 ms | | reader | 17 | 55.7 MB (51 %) | 88 ms | `/api/ang/712` 22 ms, `/api/health` all-true 45 ms, `/api/random` 258 ms | | verify | 6 | 44.2 MB (41 %) | 119 ms | `/api/verify` 28 ms | | knowledge | 7 | 0.7 MB (< 1 %) | 94 ms | `/api/timing/clock` 1 ms | Findings: every slice boots in about a tenth of a second and serves its routes; routes of other contexts return 404; the reader slice passes every `/api/health` check (FTS5, Mool Mantar, 568 ੴ, live verify). No service needs more than ~75 MB of data, so the smallest paid instance class is sufficient for each; the total slice footprint (240 MB) is about twice the single database because `lines` + `fts` are shared by four contexts. Measured on a MacBook Air (Apple silicon) with warm file caches — cold-start on the host is Spike S2's job. ## Spike S1 — CDN caching of the proxied API (observed 2026-09-24 on production) Vercel caches responses of the external `/api/*` rewrite by the API's own headers: `/api/ang/*` (`s-maxage=3600`) goes MISS then HIT, and `s-maxage` is stripped before the browser; `no-store` routes (`/api/search`, `/api/random`, `/api/health`) are never cached. Vercel's documentation does not promise that a new deployment clears cached external-rewrite responses, so both deploy workflows now purge the CDN cache right after the web goes live (`vercel cache purge --type cdn`, available in the pinned CLI), and the data canary checks through the CDN as well as at the origin. ## Performance baseline (measured 2026-09-24, before the split) `tools/perf_baseline.py` measures a fixed request mix per context against one origin and records p50/p95 (`docs/perf/baseline-2026-09.json`, 60 requests per route, 2 in flight, API origin, from Sydney): reader p95 568 ms, insights 524, search 453, knowledge 375, verify 365 — p50 about 300 ms everywhere, i.e. network round-trip dominates (server-side work is 1–200 ms, see Spike S3). After the split, `--compare docs/perf/baseline-2026-09.json` from the same vantage point fails if any context's p95 is more than 10 % worse. ## Hosting: Vercel functions in the web project (ADR-0011) The contexts first ran on staging as five free Render services. They now run as Python functions in the web's own Vercel project — one per context plus `all` — generated at deploy time by `tools/build_api_functions.py` and routed internally, so production can move with no new hosting (the web is already on Vercel Pro; functions are billed by use from the plan's credit). The trial on a separate Vercel project passed the whole golden contract (266 records at the time) on Vercel's Python 3.12 / SQLite 3.40.0, with every function ready at its commit and holding exactly its context; it also showed why the functions belong in the web project (one edge and one firewall instead of two, and no framework-less static output that would publish the databases). ## The gateway (staging, Phase 4 step 3) `frontend/vercel.json`'s rewrites are generated by `tools/gen_gateway.py` from `serve.ROUTES` (every route is tagged with its context, so a prefix can only go to the service that serves it) and from `gateway/routes.json`, which lists per environment the contexts that have their own service. A test fails if `vercel.json` drifts. Staging routes contexts one at a time — first `knowledge` (`/api/timing/*`, `/api/forms`, and their `/api/v1` twins) — and the `deploy-staging` verify job proves each routed context answers through the real gateway with its own `X-Service`. Production (v1.3.10, and 1.4.0) still sends everything to `all`; the change that routes all five contexts for production is held back (owner decision 2026-10-11), and they go live together in a later release, after the app is on the App Store ([services-production](https://docs.gurbanisoul.com/process/runbooks/services-production/), step 3). **`dataset_stats` is not needed.** The plan proposed a precomputed table so the reader slice could answer `/api/meta`'s cross-context counts. Measured on staging, the reader service's `/api/meta` is identical to the single API's (all 17 fields): its declared tables already cover everything `/meta` reports, which the authorizer test also enforces.