Skip to content

Invariants & guardrails

Read this before you touch anything. These are the load-bearing rules of the system — most are enforced in code and/or tests, but a few can be broken silently. Each links to the chapter that explains the why.

The must-not-break list

  1. tenantId comes only from the trusted edge header — never from anything a shopper can set (not the public Host, not a query param, not a cookie). The private origin fails closed without x-edge-auth. Breaking this is a cross-tenant data leak. → Multi-tenancy, Edge internals

  2. All tenant data goes through forTenant(id) (deny-by-default). A query with no tenantId can't be expressed — it throws. Never write a raw cross-tenant query. A blocking CI test (cross-tenant-authz.test.ts) proves tenant A can't touch tenant B. → Architecture, Control-plane internals

  3. Only verified = true domains route. The edge resolves host → tenantId only for verified domains; an unverified squat must never serve. → Multi-tenancy

  4. The two Liquid engines must stay byte-identical. packages/builder-render/src/engine.ts and its hand-copied twin worker.mjs both register money/asset_url/safe_url/sanitize_html. A filter change must land in BOTH, plus a new case in the parity test (liquid-render.test.ts). The classic bug: money didn't divide by 100 in the worker copy → every merchant-section price rendered 100× too high. → Render engine & isolate

  5. Prices are paise, end to end. The backend returns integer paise; the money filter divides by 100 at render. The paiseAmount helpers round only, never ×100. A stray conversion renders 100× off. Exception: the client-side checkout hydrate script treats amounts as rupees — don't carry the server convention into it. → Commerce internals, Commerce data & resolver

  6. Per-user content must be an island — never rendered into the shared shell. The registry rejects a per-user section that isn't an island. Islands are no-store and answer 204 (never 5xx) on error, because the edge circuit breaker is shared across every tenant in an isolate. → Render engine & isolate

  7. Untrusted merchant Liquid runs only in the worker isolate. It has a hard 2s wall-clock kill (a different mechanism from the 100ms cooperative in-engine budget) so one runaway can't starve co-tenants. Don't move untrusted rendering in-process. → Render engine & isolate

  8. Fail loud — never serve a partial page. A missing bundle, broken layout, or timed-out section is a 500, so the edge serves last-good rather than caching a broken page. Don't add a catch that returns a half-rendered page. → Rendering & themes

  9. The edge stays Node-free. The Worker runs on workerd — no node:fs/stream/worker_threads, no pino. Edge code imports @ratio/observability-edge, never @ratio/observability (pino pulls Node deps). This is caught only by a CI grep — local checks and PRs won't flag it. → Testing & CI

  10. Merchant-controlled output must be escaped. LiquidJS doesn't auto-escape; values go through | escape, | safe_url (blocks javascript:/data:), or | sanitize_html. Some (GoKwik) stores loosen the CSP, so output is not a safe XSS backstop. → Rendering & themes · the storefront CSP

  11. Don't cache a per-user response. A set-cookie, no-store, or private response is never entered into the shared cache. A page that forgets s-maxage/max-age is silently never cached (no error — just cost). → Edge internals

  12. New store-less mutating admin route ⇒ add denyNarrowedScope. Routes without a :id (e.g. POST /stores, /assistant) need it, or a store-scoped agent token could act unscoped — an auth hole. → Control-plane internals

  13. Adding a workspace? Update the Dockerfile. The origin image hand-copies each workspace package.json before a --frozen-lockfile install; a new workspace not in that list breaks the origin build. → Deploy runbook

The three that break silently

Most of these fail loudly (a throw, a rejected registration, a red test). #4 (dual-engine drift), #5 (paise), and #11 (forgotten s-maxage) do not — no error, just wrong prices or an uncached page. Treat any edit near a Liquid filter, a price, or a Cache-Control header with extra care, and check the relevant chapter's gotchas.