Three strata — Kernel · Meta · Modules
One Cloudflare Worker, one D1 database, three strata. Each stratum has a single responsibility; the boundaries between them are explicit. Nothing in the Kernel knows what a customer is; nothing in a module knows what another module is. That isolation is the whole point.
+----------------------------------------------------+
| MODULES |
| Self-contained business bricks — each owns its |
| own markup, styles, client JS, API routes, SQL. |
| Customers · Settings · (next: Sell, Fix, Rent...) |
+----------------------------------------------------+
| META |
| Auth · Permissions · Audit · Events |
| The seam every module hangs off — who is calling, |
| are they allowed, did it get recorded? |
+----------------------------------------------------+
| KERNEL |
| Registry · Router · DB · Shell · HTTP · helpers |
| Knows nothing about business. Boots the Worker, |
| routes requests, renders the chrome, lights up |
| whatever modules are registered. |
+----------------------------------------------------+
Kernel — src/core/*
The substrate every higher stratum sits on. Twelve files, each one a single concern:
| File | What it owns |
|---|---|
core/db.ts | The D1 binding and the SQL helpers (q, run, first) every other file uses |
core/http.ts | Request/Response helpers — html(), json(), notFound(), redirect() |
core/registry.ts | The module registry: register(m), tabs(), find(name) |
core/module.ts | The Module + TabMeta types — the contract every module honours |
core/layouts.ts | The shell + tab-strip + sign-in markup the Kernel renders around module screens |
core/auth.ts | DB-backed sessions; the Actor resolver every request passes through |
core/pin.ts | PIN hashing + verify (PBKDF2-SHA256) |
core/perms.ts | Server-side permission checks (resolveActor, the can() data catalog) |
core/perms-client.ts | Client-side permission gates: kbCan, kbPermissions, kbCurrentRole, kbApplyPerms, kbPermsRefresh |
core/audit.ts | The tamper-evident audit chain — recordMutation() |
core/responsive.ts | The breakpoint chassis (shared layout primitives across modules) |
core/datepicker.ts | kbDatePicker — date-range picker (shared by every module) |
core/pager.ts | kbPager — keyset pagination helpers (no silent caps) |
core/sortable.ts | kbSortable — drag-to-reorder helpers |
core/print.ts | kbPrint — print preview + print chassis (extracted from Customers in v1.0.0) |
core/scan.ts | Barcode / serial-number scan input |
core/search/engine.ts | FTS5 search engine — BM25 ranking + per-token prefix |
core/search/synonyms.ts | The synonym layer; modules register their domain-specific synonyms |
The Kernel knows nothing about customers, bikes, transactions, or tax. It boots the Worker, hands the request to the right module, and gets out of the way.
Meta — the seam every module hangs off
The Meta layer is the who · allowed · recorded seam. It lives in the Kernel folder (src/core/auth.ts, perms.ts, audit.ts) but is conceptually distinct:
- Auth — every request resolves an
Actor(the signed-in staff member) before routing to a module. Sessions are DB-backed; cookies are HttpOnly + SameSite=Lax. The PIN-screen splash is Core chrome. - Permissions — every CTA in every module checks a keyed permission (
customers.edit,customers.delete,settings.staff.write, etc.) both client-side (to hide/disable affordances) and server-side (to refuse the mutation). The catalog lives in migration 007; the resolver iscore/perms.ts. - Audit — every mutation goes through
recordMutation(). Each row'schain_hashincludes the previous row's hash, making the chain tamper-evident. See Audit-everything.
The Meta layer is what makes a module safe to add. A new module declares its permissions, asks the Actor resolver for the caller, and writes through recordMutation. Get those three things right and the module is wired into the rest of the system.
Modules — src/modules/{name}/
Each module is a sealed brick. The contract every module honours:
export interface Module {
tab: { name: string; label: string; accent: string; order: number };
screen(): string; // returns the full <HTML><CSS><JS> for the tab body
api: ApiHandler[]; // server routes the module owns
}
A module owns:
view.ts— markup + styles for the operator screenclient.ts— browser JS that wires the screenapi.ts— server-side route handlers (read/write to D1)index.ts— the module'sregister()call
The Kernel boots the registry; the registry imports src/modules/index.ts; that file imports every module's index.ts; each module calls register(self) at import time. The tab strip then renders from tabs() in display order. The Kernel never sees inside a module; the module never sees another module.
Modules shipped today
| Module | What it does | Order |
|---|---|---|
| Customers | Customer search (FTS5) · 3-pane work area (search / swappable ledger / read-only Till) · 10+ section pills opening single-purpose popups · Contact / Linked / Family / Marketing / Notes / Equipment / Loyalty / Identity / credit-debit / Merge / Print · loyalty enrol · PIPA export/erase | 10 |
| Settings | Users + Staff management (Employee modal) · read-only Permissions inspector · Discount builder (3-step authoring; per-department applies-to; assigned per-customer) | (admin) |
Modules planned
Sell · Fix · Rent · Maintenance · plus whatever a future shop needs. A module that isn't enabled doesn't ship code — toggling is at the registry level, not a runtime config flag.
Why this shape
Three strata, drawn this strictly, give four properties the monolith couldn't:
- A new module is additive. Drop a folder in
src/modules/, register it, ship. The rest of the system can't see it; you can't break the rest of the system by writing it wrong. - A module can be removed. Delete the folder, remove the import from
src/modules/index.ts, ship. Its tab disappears. The Kernel doesn't notice. - Per-shop customisation is a fork at the module level, not a config branch buried in shared code. Two shops disagree about the Customers tab? Fork the module folder. The Kernel they share stays clean.
- The Bible can grow by module. The pages on a module live in
domain/modules/{name}.md— when a module ships, its Bible page lands in the same commit window. When it's retired, the page goes with it.
See also
- Bespoke Web System — the platform shape this implements
- Substrate Line — what's frozen (Kernel) vs free (modules)
- The Doctrine — why per-shop adaptability is the product
- Customers module — the first module
- Settings module — the admin surface