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. ~66 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). Strip order comes from setTabOrder() (v0.7.409) — a single explicit list of module names in src/modules/index.ts decides the sequence, replacing the earlier per-module TabMeta.order number (which was a shared namespace with no allocator — two modules could collide, and eComm + Trades did at v0.7.405). |
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. Bootstrap admin resolves by role, not id=1 (v0.7.105) |
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(), plus recordAuditEvent() (FULL-coverage tamper hash) + recordNarrated() + the recentAuditEvents reader (v0.7.182–.185). Feeds /api/audit/recent and the ticker footer. See Audit narrator + ticker. |
core/audit-narrate.ts | The deterministic verb / lexicon library narrate(event) — one switch over event_type, resolves substrate refs (variant #N, customer_id, staff_id) into readable names + verbs + actor. Stamped on the audit row at write time, inside the hash. |
core/responsive.ts | The breakpoint chassis — BIKE.L1-0001. Phone ≤640, tablet 641–1024, desktop ≥1025. The sales-3zone split is 40/60 on tablet, 50/50 on desktop (updated v0.7.96) |
core/datepicker.ts | kbDatePicker — the shared date-range picker mounted on every module's ledger heading (BIKE.L1-0010, v0.7.97/98) |
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 during the v1.x prototype period; that whole line is folded into the v0.7.0 baseline, so the label survived while the version number didn't). |
core/scan.ts | Barcode / serial-number scan input |
core/search/engine.ts | FTS5 search engine — BM25 ranking + per-token prefix; family-name rerank tier (v0.7.92) |
core/search/synonyms.ts | The synonym layer; modules register their domain-specific synonyms |
core/ui.ts | Shared design-system classes — kb-modal*, section / row vocabulary, buttons, chips, the Customers norm modules conform to; the per-tab color-keyed accent line under every .page-header (v0.7.93); .kb-link — subtle black dashed underline, sanctioned "this text is clickable" cue for text that navigates or opens (customer name, transaction #, linked account) — Tom-overruled the earlier no-underline-links ban 2026-07-21 (v0.7.452). NOT for buttons / pills / footer actions — those have their own affordances. |
core/router.ts | The keep-mounted SPA router — modules mount once and persist across tab switches; tab clicks toggle visibility, never tear down. kbGoto(tab) cross-module opener (v0.7.120); kbEnsureModule(name) mounts a module hidden without switching tabs (v0.7.260) — enables cross-module editors (Sales product-click opens Inventory's editor in place). BIKE.L1-0008. |
core/stepper.ts | kbStepper — one shared −/+ control (v0.7.114, BIKE.L1-0013). Mounted on Sales cart quantities, Purchases bucket + new-PO staging, Sales Return panel qty. |
core/customeredit.ts | Shared Edit-customer control (v0.7.185, folded into by v0.7.311). Opens in place from the Sales ledger heading when the operator clicks a customer name on a historic transaction. Also carries the account-credit + credit-adjustment surface (v0.7.311). |
core/keys.ts | Core keyboard layer (v0.7.282) — LIFO Escape stack + shortcut registry + onEscClear per-tab reset + "?" cheat-sheet. window.kbKeys.pushLayer / register / onEscClear. |
core/scaffold.ts | Scaffolding tooltips engine (v0.7.303). window.kbScaffold(el, key) — per-staff hover hints with decay curves + idle-past-window re-teach + keyboard-Tab-only reveal gate. Reads migration 044's feature_registry (129 seeded hints); tracks state in migration 043's staff_feature_state. |
core/noautofill.ts | Site-wide autofill / autocomplete kill (v0.7.283). MutationObserver stamps every input to prevent the browser from prefilling a POS field from unrelated history. |
core/splitpane.ts | Till column resizer (v0.7.244–.246, v0.7.239 un-bundling). |
core/versionmodal.ts | Version-notes popup (v0.7.291) — auto-opens once per build via localStorage; Escape via kbKeys; renders from core/changelog.ts. |
core/changelog.ts | Build-time generated from git log by scripts/gen-changelog.mjs (a wrangler [build] hook); gitignored artifact. Exports VERSION, CHANGELOG, WHATS_NEW, GENESIS_UTC. Every code-altering commit → one entry; each entry carries technical (subject + body) + operator (Plain:) layers. See Changelog. |
core/live.ts | kbLive — one shared live-refresh heartbeat (BIKE.L1-0021, v0.6.988). A module calls kbLive.register({ name, tick, busy }) on boot; Core runs tick() on a fixed cadence for the active tab only, refreshes immediately on tab-return, pauses while the browser tab is hidden, and always skips a beat while a .kb-modal.open is present or the module's busy() reports true — so an auto-refresh never yanks the screen out from under an operator mid-transaction. Wired into Sales + Purchases + all data tabs. |
core/kits.ts | Kit engine (mig 061, v0.7.015). One shared applyKitLine that translates a kit-variant sale into component-stock movements; selling a kit consumes components, reversing (refund / void / exchange) restocks them. Kit variant itself is notional — its own on-hand is never touched. Wired into all 7 Sales stock sites; Service /invoice shares the same code path (single-decrement-function rule). Every component move still passes through core/movements.ts. Skips service / labour components by stock_behavior. |
core/suppliers.ts | Supplier reference-data engine — the dedup layer (mig 062). One home for supplier create / rename / merge / duplicate-detect, shared by Purchases + Inventory (their POST/PUT /supplier handlers were byte-for-byte duplicates before). Every write keeps suppliers.name_key in sync via normalizeKey; match-before-create HARD-BLOCKS a second active supplier with the same normalized name (Tom's call). Merge repoints 4 child tables + soft-deletes the loser (is_active=0). Partial-unique index idx_suppliers_name_key_active is the DB backstop. |
core/text-normalize.ts | Text normalization for reference-data hygiene. normalizeKey (case/space/punctuation/accent-insensitive slug — the dedup layer's canonical form); normalizeModelName (bike model text: trim, collapse whitespace, title-case each word without mangling model codes — digit tokens 29er and short caps FX/VLT/SS/ENVE kept as typed). Deliberately does NOT fix misspellings — that's a curation-tool job, not a deterministic rule. |
core/bike-brands.ts | The definitive Canadian bike-brand list (173 makes). Fed to autocomplete on Service intake + Inventory + wherever a Brand field is authored, so "Norco" / "NORCO" / "norco " stops producing three catalog entries. |
core/api.ts | The server-call chokepoint (01d1430, Step 0 of offline). Every browser-side call to the Hub Worker passes through one kbApi primitive — that gives the offline gating a single place to intercept (say-so instead of dead buttons — f7005da), the idempotency layer a single place to add + retry keys, and the mutation queue a single place to enqueue writes when the line is down. |
core/idempotency.ts + migrations/137_idempotency_records.sql | Ported from the Monolith (its mig 014, 13 May 2026 — f893530). The client mints ONE key per intent (one sale, one refund, one deposit payment); the server persists idempotency_records and refuses to run the same key twice. Extended in 906aa73 to refund, exchange, deposits, no-receipt returns; 16fc766 gives each leg of a split-tender deposit its own key. Without this, a flaky retry books the money twice. |
core/localcache.ts | The reference data a till needs when the line is down (52accb4). Products, customers, service menu, tender flags — read-through cache in the browser (IndexedDB) that survives a reload; kbApi tries the network, then falls back on the cache for the reads the till can safely serve locally. Customer search answers from the cache when the line is down (240cb1d). |
core/mutationqueue.ts | Writes that survive the line going down (7505217). A mutation kbApi cannot post is enqueued locally, badged in the UI, and posted when the line returns — using the idempotency key from core/idempotency.ts so the drain-on-reconnect never double-books what the offline retry already delivered. |
core/serviceetc.ts + migrations/108_service_bench_settings.sql | The ETC computation — the estimated time of completion the Service ledger stamps on each ticket. Arithmetic is straightforward (sum labour minutes ahead in the queue, spread over the bench) but three inputs are the shop's to answer (mig 108: bench count, effective bench hours per day, working days per week). Guessing produces a date the shop stops trusting inside a week, which is worse than no date. |
core/sigpad.ts | Signature pad primitive — the same canvas-signature capture the rental waiver uses, extracted for reuse by Service customer sign-off. Base64 PNG output. |
core/rentaldamage.ts | Rental damage capture — extracted from rentals/api.ts after the sixth in-line copy of the same computation. One authority for damage-charge amount + tax exemption + hold capture semantics; both the Rentals till and the Sales till reach the same numbers via this file. |
core/rentallate.ts | Rental late-fee authority (Tom 2026-08-01, "the late fee needs to be in the Rental settings"). Model = auto-extend: a rental returned late keeps billing the bike's own per-period rate_cents for each period held past due, past a grace window. Both the Rentals GET /late-quote (display) and the Sales POST /late-capture (money) call THIS — one authority, never two drifting copies. Fee is pre-tax; caller adds GST. Reads shop_config.rental_late_fee_enabled + rental_late_grace_minutes (mig 086). |
core/rentalcancel.ts | Rental cancellation authority. One primitive that consumes shop_config.rental_cancel_policy (full_refund / non_refundable / refund_minus_fee / non_refundable_within_hours) + rental_cancel_fee_cents + rental_cancel_window_hours (mig 089) and returns the money verdict — full refund, kept as exempt liquidated damages, or partial. The Sales till acts on the verdict; Rentals never computes the money itself. |
core/rentalconvert.ts | Rental → sale primitive — takes a live-booking bike line and hands it to the Sales till as a full-price sale, flips the line's line_status to sold, retires it from the live booking. Sold lines are excluded everywhere swapped_out is excluded (mig 088 widens the partial-unique-index exclusion accordingly). |
core/rentalearlyreturn.ts | Early-return proration authority. Consumes shop_config.rental_early_return_policy (no_refund / prorate / prorate_minus_fee) + rental_early_return_fee_cents + rental_early_return_cutoff (mig 089) and returns the refund amount for unused whole days at the bike's daily rate. |
core/rentalextend.ts | Extend a live rental — live-quote for extra periods at the bike's own rate_cents, hard-stop on booking conflict (a bike already reserved on the extended dates by another booking), GST-taxable charge for the extra time. The late-fee path calls this to advance the due date after a late-capture (fixes late-then-extend double-charge). |
core/movements.ts | The inventory-movement engine — extracted from the former Products module in v0.7.69 so Sales, Service, and Inventory all decrement stock through the same code path. Writes one immutable inventory_movements row per change; carries the allowNegative flag for backorder / oversell. |
core/formats.ts | kbFmt — field & format primitives (money-cents, ISO dates, percent, phone) used across every module's read + edit views. The data-mask="money" primitive (v0.7.210) — type=text inputmode=decimal, strips commas on focus, shows 5,999.99 on blur — is central so no editable money surface can undercount by parsing $5,999.99 as $5. Phone mask (v0.7.123, BIKE.L1-0002) sits alongside it. |
core/focus-steward.ts | The focus-stewardship doctrine (BIKE.L2-0195/0196, v0.7.87 → v0.7.91). One steward input per active module; modal-close / afterprint / blur-to-nothing / 5s keyboard idle refocus back to it. Scanner-burst interception at capture phase redirects into the steward regardless of where focus drifted. |
core/hovercard.ts | kbHoverCard — shared ledger row hover-preview shell (v0.7.104, BIKE.L2-0204). One popover at document.body level (escapes ledger card overflow:hidden), per-row arm delay, fetch-or-local data hook, viewport-aware positioning, race guard on late fetches. Sales mounts it first; Service / PO / Inventory / Customers reuse the shell later, each supplying its own card content + data source. |
core/access-gate.ts + core/unlock.ts + core/unlock-pages.ts + core/unlock-routes.ts + core/public-paths.ts (2026-09-15) | The morning-unlock chain. Cloudflare Access stands in front of the Worker and emails a one-time code to the people allowed in; core/access-gate.ts is the Hub's own check that a request arrived through that sign-in (so a misconfigured Cloudflare dashboard can't silently open the till). core/unlock.ts — the rules for the morning unlock (Google OAuth → texted code to a different device → per-staff PIN). core/unlock-pages.ts — the unlock UI, styled to match the PIN splash so a locked till looks like part of the Hub. core/unlock-routes.ts — the routes that serve those pages + the gate that runs ahead of every authenticated route (behind only the small public list). core/public-paths.ts — the ONE list of addresses openable without Cloudflare sign-in (public-door.ts forwards them, access-gate lets them through — two copies would be two rules to drift). |
core/limits.ts (2026-09-15) | Wrong-PIN + rate limits. Staff PINs are 5 digits, admin 6; before this a script could work through them unseen. limits.ts locks a device after N wrong PINs and rate-limits how often one address may open a public booking. |
core/customerpicker.ts | window.kbCustomerPicker — the ONE attach/add-a-customer picker for every till (Sales / Rentals / Service). Markup lives in the shell (#cp-modal at body level) so it overlays whatever module pane is active. The caller owns what a pick MEANS via callbacks; the picker owns the UI. Extracted from Sales so Rentals opens the same modal and creates the same customer type. |
core/note.ts | kbNote — one transient message line, shared. NOT kbToast (retired July 2026, replaced by the footer audit ticker) — there is no queue, no stacking, no corner-of-screen that owns messages. kbNote puts ONE line in a place the calling module names, which is the pattern six modules arrived at independently after the retirement. |
core/notesign.ts | Auto-append staff initials to a work-order note (James, note #63). "If James Durand enters notes in Bike In, -JD is added at the end automatically. If Robbie van Boot enters notes in Mechanic's, -RvB is added." One primitive; every note field in Service reads through it. |
core/namerank.ts | How a name search orders its answers (James, note #67). "When I type Wes, I want Wes Cade first, then Wes Smith, then Wesley & Catherine Sharpe, then Jane West." One primitive that shared search domains (Customers, Waiver signer picker) call so the ordering rule is single-source. |
core/navstrip.ts | Nav-strip equal-width sizer. Every tab in a user's strip is the SAME width, sized to the longest LABEL in that user's permission-filtered set — a cashier who sees only Sales + Service gets two tidy equal tabs, not two padded out to "Migration". |
core/rentalearnings.ts | Rental earnings per bike, defined once (James, note #40). "I'd like the rental tab to calculate how many rentals each bike has experienced, and the earnings based on the amount of rentals in any bike's life span." Reused by the Inventory Stock Card's Rental earnings + history accordion and the Rentals fleet view — one source, so the two surfaces can't drift. |
core/servicedeposit.ts | A work order's deposit column, kept in step with the deposit ledger. Money can land on a service deposit from two modules — Service takes one at drop-off; Sales takes one when the same work order is topped up at the till (James, note #22). Both have to leave service_tickets.deposit_cents saying the same thing; a module cannot import another module, so the rule lives here and both call it. |
core/shopday.ts | What day it is in the shop. Cloudflare Workers run on UTC, so new Date().toISOString() gets UTC's date. For a timestamp that's right; for a shift date it's wrong — Vancouver is 7–8 hours behind UTC, so UTC's day turns over at 4–5 pm local, with the counter still open. shopday.ts reads the shop's timezone from shop_config and returns the local shift date, so "today's sales" means the current shift, not the current UTC day. |
core/stockfacts.ts | The three numbers a stock line has to answer (James, note #73). Search-hit rows show On hand · Available · Sold last 12 months — where Available = on-hand minus committed on open layaway or service order but not yet invoiced. Defined once so every ledger surface reads the same numbers. |
core/tax.ts | The tax-category resolution chain — ONE definition for every caller. The same COALESCE(inventory, product.default, category.default) was written five times across Sales (four sites) and Inventory (once). Four are what the till previews; one is what it charges. Edit four and miss one and the preview disagrees with the charge, silently, with both numbers looking plausible. Extracted so preview and charge cannot drift. |
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 };
screen(): string; // returns the full <HTML><CSS><JS> for the tab body
api: ApiHandler[]; // server routes the module owns
}
(An earlier form of TabMeta carried order: number here. As of v0.7.409 the field is optional and ignored; the module doesn't choose its strip position. Strip order comes from an explicit list of module names inside src/modules/index.ts, passed to Core via setTabOrder(), so there's only ever one authoritative source for the sequence and no way for two modules to collide on a number.)
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 and calls setTabOrder([...]) with the canonical strip sequence; each module calls register(self) at import time. The tab strip then renders from tabs() in the order the list dictates. The Kernel never sees inside a module; the module never sees another module.
Modules shipped today
Strip order comes from the setTabOrder([...]) call at the foot of src/modules/index.ts; the list below matches that array left-to-right. See the intro's shipped-modules table for the full one-line descriptions.
| # | Module | Role |
|---|---|---|
| 1 | My Hub | Staff home surface — weather chip · End-of-Day report · Shop Tasks · Shop Board · Messages · owner reporting pane |
| 2 | Sales | The POS sell screen (sales-3zone chassis — the UI template every other till follows) |
| 3 | Service | Workshop work-orders — intake modal · ETC on the ledger · customer sign-off · parts-hold with vendor + ETA |
| 4 | Customers | Customer search + ledger + assets (bikes-as-assets, consignment payouts, loyalty ledger, worklists) |
| 5 | Purchases | PO ledger + Purchase Cart + Vendors + PO lifecycle |
| 6 | Inventory | Stock console — SKU search · variant detail · movement ledger · reorder dashboard · Rental fleet + rate tiers |
| 7 | eComm | Layout preview only — declared tab, no client JS / no API / no DB. See intro's eComm note. |
| 8 | Rentals | Booking lifecycle · Fleet / List / Calendar three-lens · rate cards · waivers · damage-hold ladder · money handoff to Sales |
| 9 | Website | Public marketing site, framed inside the Hub · editable-in-place · Reserve Your Bike funnel |
| 10 | Channel | Brand's view of its own dealer channel — four read-only views, owner-only (screen.channel) |
| 11 | Settings | Users + Staff · editable Permissions Inspector · Rental / Service / Loyalty / Tax knobs |
| 12 | Migration | sys-admin only — AIM cutover tool with rebuild + amend modes |
Plus one hidden module the strip does not render:
| Module | Why hidden |
|---|---|
| Notes | Private notebook opened from the brand wheel — gated by a two-person identity allowlist, not a permission; never renders as a tab, never offers itself as a shelf inside its own notebook |
Trades tab is gone (mig 103) — trade-ins live on inventory_units.is_trade_in, not a tab of their own.
A module that isn't enabled doesn't ship code — toggling is at the registry level (import + setTabOrder slot), 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