Skip to main content

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:

FileWhat it owns
core/db.tsThe D1 binding and the SQL helpers (q, run, first) every other file uses
core/http.tsRequest/Response helpers — html(), json(), notFound(), redirect()
core/registry.tsThe module registry: register(m), tabs(), find(name)
core/module.tsThe Module + TabMeta types — the contract every module honours
core/layouts.tsThe shell + tab-strip + sign-in markup the Kernel renders around module screens
core/auth.tsDB-backed sessions; the Actor resolver every request passes through
core/pin.tsPIN hashing + verify (PBKDF2-SHA256)
core/perms.tsServer-side permission checks (resolveActor, the can() data catalog)
core/perms-client.tsClient-side permission gates: kbCan, kbPermissions, kbCurrentRole, kbApplyPerms, kbPermsRefresh
core/audit.tsThe tamper-evident audit chain — recordMutation()
core/responsive.tsThe breakpoint chassis (shared layout primitives across modules)
core/datepicker.tskbDatePicker — date-range picker (shared by every module)
core/pager.tskbPager — keyset pagination helpers (no silent caps)
core/sortable.tskbSortable — drag-to-reorder helpers
core/print.tskbPrint — print preview + print chassis (extracted from Customers in v1.0.0)
core/scan.tsBarcode / serial-number scan input
core/search/engine.tsFTS5 search engine — BM25 ranking + per-token prefix
core/search/synonyms.tsThe 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 is core/perms.ts.
  • Audit — every mutation goes through recordMutation(). Each row's chain_hash includes 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 screen
  • client.ts — browser JS that wires the screen
  • api.ts — server-side route handlers (read/write to D1)
  • index.ts — the module's register() 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

ModuleWhat it doesOrder
CustomersCustomer 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/erase10
SettingsUsers + 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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