Appearance
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
tenantIdcomes only from the trusted edge header — never from anything a shopper can set (not the publicHost, not a query param, not a cookie). The private origin fails closed withoutx-edge-auth. Breaking this is a cross-tenant data leak. → Multi-tenancy, Edge internalsAll tenant data goes through
forTenant(id)(deny-by-default). A query with notenantIdcan'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 internalsOnly
verified = truedomains route. The edge resolveshost → tenantIdonly for verified domains; an unverified squat must never serve. → Multi-tenancyThe two Liquid engines must stay byte-identical.
packages/builder-render/src/engine.tsand its hand-copied twinworker.mjsboth registermoney/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:moneydidn't divide by 100 in the worker copy → every merchant-section price rendered 100× too high. → Render engine & isolatePrices are paise, end to end. The backend returns integer paise; the
moneyfilter divides by 100 at render. ThepaiseAmounthelpers 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 & resolverPer-user content must be an island — never rendered into the shared shell. The registry rejects a
per-usersection that isn't an island. Islands areno-storeand answer 204 (never 5xx) on error, because the edge circuit breaker is shared across every tenant in an isolate. → Render engine & isolateUntrusted 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
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
catchthat returns a half-rendered page. → Rendering & themesThe edge stays Node-free. The Worker runs on workerd — no
node:fs/stream/worker_threads, nopino. 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 & CIMerchant-controlled output must be escaped. LiquidJS doesn't auto-escape; values go through
| escape,| safe_url(blocksjavascript:/data:), or| sanitize_html. Some (GoKwik) stores loosen the CSP, so output is not a safe XSS backstop. → Rendering & themes · the storefront CSPDon't cache a per-user response. A
set-cookie,no-store, orprivateresponse is never entered into the shared cache. A page that forgetss-maxage/max-ageis silently never cached (no error — just cost). → Edge internalsNew 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 internalsAdding a workspace? Update the Dockerfile. The origin image hand-copies each workspace
package.jsonbefore a--frozen-lockfileinstall; 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.