13 — Architecture decision records
Every decision serves density, isolation, and development speed. Decisions changed from v1 are marked so the reasoning can be revisited if conditions change. Status values: accepted, proposed, superseded.
To change a decision: add a new ADR (next number) with status proposed, reference the one it supersedes, state the evidence (benchmark, incident, user feedback), and update the old one's status after acceptance. Never edit an accepted ADR's reasoning in place.
| # | Status | Decision | Reasoning | Rejected alternatives |
|---|---|---|---|---|
| D1 | accepted | Goal: a hosting platform for third-party code, not a framework for our own apps | Multi-tenant sandboxing is the core product and revenue source. This decision justifies every technical one below | A TS framework on Bun for our own products (cheaper, but no sandbox and no hosting business) |
| D2 | accepted | The data plane is written in Rust | Full control over V8 isolates: heap limits, CPU watchdog, op permissions, scheduling. Not available in Bun/Node | Bun process per tenant (30–50 MB per tenant, 5–10x less dense) |
| D3 | accepted (changed in v2) | The runtime is built on Supabase Edge Runtime / deno_runtime, not raw deno_core | Web APIs, npm:, node:*, source maps, per-worker memory/CPU limits already exist and were built to run customer functions. Cuts the largest piece of scope | Raw deno_core (rebuild Node compatibility), QuickJS (10–50x slower), Bun (no isolates) |
| D4 | accepted (new) | The control plane is written in Bun (TypeScript) | Our own code, no sandbox needed, development speed matters most there | Rust for everything (slow for CRUD and dashboards) |
| D5 | accepted (changed) | Tenant data in SQLite per tenant; control plane in Postgres | File-level isolation is stronger than RLS, idle tenant cost is zero, in-process queries take microseconds, backup and tenant moves are trivial. Used by Cloudflare D1, Turso, Fly.io, PocketBase | Postgres schema per tenant (catalog bloat, RLS as the only fence), Postgres DB per tenant (expensive connections, hard per-tenant restore) |
| D6 | accepted | Layered isolation: V8 isolate + sandboxed process (seccomp, namespaces, cgroups) + process tiers | Isolates alone are not enough for untrusted code (side channels, V8 bugs) | Isolates only (even Cloudflare does not), microVMs for everyone (too expensive for the long tail) |
| D7 | accepted | HTML rendered in Rust (minijinja), JS returns data only; a template-to-JS path is provided and the default is chosen by benchmark | Rendering outside the tenant quota, small tenant heaps, templates as a DSL without code execution. V8→Rust marshaling cost is real and must be measured | JS-only SSR as the single path |
| D8 | accepted (changed) | Attribute-based HTML template syntax, transpiled to minijinja at deploy | Templates stay valid HTML and integrate with htmx/Alpine; performance, escaping, filters, and per-block render come from proven minijinja. The custom part is only a syntax translator | A custom render engine (bugs, missing features), Jinja syntax directly (block syntax rejected by the author) |
| D9 | accepted | Interactivity via HTML fragments + htmx + idiomorph + View Transitions | Partial updates without an SPA, DOM state and animations preserved, small client JS | React/SPA |
| D10 | accepted | Convention over configuration for routing | No router boilerplate; folder structure = URL structure | Manual router configuration |
| D11 | accepted | A module is a mini app with the same folder structure; official modules (auth, billing, admin) are the product | One concept to learn; modules are the main reason tenants choose Renox | A separate plugin API |
| D12 | accepted (changed) | Module isolation modes: shared for the tenant's own and official modules, isolate for marketplace modules | Own modules need no serialization between contexts; other people's modules must not touch the tenant heap | context as the default for all (serialization overhead without a real security benefit) |
| D13 | accepted (changed) | Tables per module use a prefix (billing__invoices) in one file per tenant; module permissions via the SQLite authorizer | Cross-module transactions stay atomic (ATTACH across files is not atomic in WAL mode); the authorizer checks table access when SQLite compiles the statement | One file per module via ATTACH, SQL parsing in Rust |
| D14 | accepted (changed) | Durable events via an outbox in the tenant file; the worker is woken by update_hook | Event and data in one transaction; no polling across thousands of files | Kafka/NATS, Postgres LISTEN/NOTIFY |
| D15 | accepted | User authorization with Cedar (cedar-policy): RBAC + ABAC + relations | Native Rust, policies loaded at runtime, schema-validated, forbid wins over permit | OPA/Rego (Go sidecar), Casbin, TS predicates |
| D16 | accepted (changed) | No RLS; row-level safety via db.scoped() + Cedar + mandatory authorization tests | SQLite has no RLS; tenant isolation is handled by file per tenant. Losing the row-level fence is acknowledged as a trade-off | Keeping Postgres only for RLS |
| D17 | accepted (new) | Forward-only migrations with expand/contract; public views are versioned | Cross-module down rollbacks break view dependencies; compatible forward migrations are safer | Classic up/down migrations |
| D18 | accepted (new) | One SQL dialect for the module ecosystem: SQLite | Dual-dialect modules are tested twice and third-party authors will support only one | Dual dialect |
| D19 | accepted (new) | Dev hot reload discards the isolate and creates a new one | Simpler and leak-free compared with hot-swapping contexts; tens of ms are acceptable in dev | Per-module context hot swap |
| D20 | accepted (new) | Trimmed MVP: F0–F2 + the auth module + two real products, with kill criteria | A framework built without products is almost always wasted | Building the whole roadmap before there are users |
| D22 | accepted (new) | D7's default is path A: pages and fragments render in Rust (minijinja) after marshaling. Path B (template compiled to JS, run in the isolate) is not offered to tenants; it stays a tested prototype | B8: path B costs the tenant more CPU than path A in total and loses its lead even at 1,000 items; under concurrency it multiplies CPU-limit terminations. Section below | Path B as the default; path B per page on opt-in |
| D23 | accepted (new) | Internal tenants (the dashboard, #57) reach the control API over its public hostname like any internet client: no exemption from tenant fetch rules, no internal route flag, no ctx.platform op. The credential is a scoped tenant token or the signed-in user's own credential, never a node token. Nodes keep refusing the control plane's node-facing addresses | No privilege lives in the data plane, so an isolate escape in the dashboard gains nothing a token holder on the internet lacks; no contract change; one code path for every tenant. Section below | (b) ctx.platform op for tenants flagged internal in the route entry; a per-node exemption from fetch_blocked_addresses for a dedicated internal cell |
| D24 | accepted (new) | Renoxium's own pages use one in-house stylesheet, renox-ui (packages/ui/): plain CSS, no build, no JavaScript, no web fonts, logical properties only, light and dark by system setting. No third-party CSS framework or component library | Every framework that fits the constraints (no build step, CSP default-src 'self', server-rendered HTML with htmx, Arabic right to left) was either unmaintained or brings a build, a client runtime or a generic look; the needed set is about fifteen components. Section below | Pico CSS (archived 2026-10-04), Tailwind or daisyUI (build step), shadcn/ui (React), Web Awesome (client-rendered web components), Bootstrap 5.3 or Tabler (large, generic look, a dependency that modules would pass to tenants), Basecoat, Franken UI |
ADR template
### D<n> — <title>
Status: proposed | accepted | superseded by D<m>
Date: YYYY-MM-DD
Context: what forces are at play (benchmark numbers, incidents, user feedback)
Decision: what we do
Consequences: what becomes easier, what becomes harder, what we give up
Alternatives considered: each with the reason it was rejected
Evidence: links to bench/results/, issues, discussions
D22 — Render path A is the default (decides D7)
Status: accepted Date: 2026-10-28 Context: D7 keeps two render paths and lets benchmark B8 choose the default. Path B was built for #42 (crates/renox-template/src/emit_js): the same compiled templates as one JavaScript module, byte-equal with path A on the whole fixture set. B8 ran the shop list page and its product_list fragment through both paths end to end on renox-worker, at 100 and 1,000 items. At 100 items (26 KB): path A p50 2.40 ms, path B 3.09 ms; tenant CPU 0.70 ms isolate plus 0.26 ms render pool against 1.48 ms isolate. At 1,000 items (253 KB): 10.9 ms against 15.2 ms; 4.03 + 2.40 ms against 10.84 ms. Under 8 concurrent connections at 1,000 items path B served 1–2 requests per second, because nearly every request ended RENOX_CPU_LIMIT (the accounting finding #155, made continuous by path B's longer isolate occupancy). Path A lost about 4 % of requests the same way. Measured on the F1 container, not node-small (#158); the ratios are large enough that the re-run is not expected to change the decision. Decision: Pages and fragments render on path A, in Rust, off the isolate thread, under the render limits of renox-template. Path B is not a tenant-facing option. The emitter and its byte-equality test stay in renox-template as a prototype, so B8 can be re-run when the inputs change. The rule for when path B applies: never by default. Re-open this decision only with a B8 run that shows path B using less total CPU (isolate plus render pool) than path A for a realistic page. Two changes could do that: data sets well beyond 1,000 items where marshaling dominates (B9), or render-pool saturation on node-small. Consequences: Rendering stays outside the tenant's CPU limit and heap (D7's reason), and tenants get one render semantics and one set of limits (fuel, iterations, output, depth, time). We give up rendering without marshaling; B9 stays the cost to watch. Path A's fragment rendering evaluates the whole page first and costs more than the page itself at 1,000 items (B8 findings), which is to fix on path A. TSX in the isolate (docs/08-template-engine.md, "Secondary option") keeps its status: not the default. Alternatives considered: Path B as the default (more tenant CPU and slower in B8, no fuel equivalent, and a JS reimplementation of every filter to keep byte-equal). Path B per page on opt-in (the B8 numbers give no page where it pays off, and two semantics double the test surface). Dropping the prototype (kept: it is small, tested, and lets the decision be re-measured). Evidence: B8 result, crates/renox-template/tests/emit_js.rs, #42, #155.
D23 — Internal tenants reach the control API over the public path
Status: accepted Date: 2026-10-29 Context: The dashboard (#57) is a Renox app (design principle 7, dogfooding) and needs the control API: apps, deployments, activate and rollback, logs, usage, secret names. Tenant fetch refuses the control plane by design: the node lists the control plane's addresses in fetch_blocked_addresses (crates/renox-cell/src/worker.rs, ExtraBlocked in crates/renox-runtime/src/ops/net_policy.rs), and controlled mode refuses to start with an empty list (#55). The F1 gate (#37) opened #164 to choose between (a) the public control-API hostname over the internet path, with no exemption, and (b) a ctx.platform op available only to tenants flagged internal in the route message (docs/contracts/deploy-api.md, Open item 5). The surfaces differ: the control API serves tenant endpoints (bearer renox_pat_ tokens and renox_ses_ sessions, scoped, per tenant, section 2) and node endpoints (/v1/nodes/*, /v1/ingest/*, bearer renox_node_ tokens) that hand out route tables, secret bundles, artifacts and certificates. The tenant endpoints are already open to the whole internet, the CLI's and the MCP server's path. Decision: Option (a). An internal tenant is an ordinary tenant. It reaches the control API at its public hostname (api.<platform domain>, the URL renox login uses), declared in its manifest's permissions.network like any other host, and passes through every rule of ops.md section 2 unchanged: allowlist, DNS in Rust, every resolved address against the blocked ranges and fetch_blocked_addresses, each redirect hop re-checked. The credential is the signed-in user's own (session or tenant token, forwarded per request) or a tenant token scoped to what the app needs (for example apps:read, logs:read), held as a tenant secret (#54) and read from ctx.secrets. It is never a node token and never a token with reach beyond one tenant. fetch_blocked_addresses keeps listing the node's own addresses and the control plane's node-facing addresses (where [control] url resolves); the public API address is neither listed nor exempt. When both surfaces share one address, the operator puts the node-facing listener on its own address before an internal tenant is deployed. There is no internal flag in route entries, no ctx.platform op, and no per-node or per-tenant exemption in Rust. Consequences: Easier: no privileged path exists in the runtime, so a V8 escape or a bug in the dashboard yields only the credentials it holds, the same a stolen CLI token gives; no contract change (route entry, ops.md, ctx.d.ts); the dashboard exercises the same API, limits and audit as every customer (dogfooding). Harder: the dashboard pays an internet round trip per API call (latency, not cost, while F3 runs one region); the operator must keep two address sets apart and the node's list current when the control plane moves; requests from internal tenants leave from node addresses, so the API must never trust a node address for tenant endpoints (it does not: control/api/src/auth/ip.ts feeds the socket address to rate limits, audit rows and new-device mail only). The per-IP buckets of sign-up, login and mail (control/api/src/auth/accounts.ts) see the node's address for every dashboard user, so the dashboard must not proxy those flows: the browser signs in against the API directly and the dashboard receives the resulting session (#57). A trusted forwarded-for rule for node addresses would be an exemption and is not added. Given up: a ctx.platform shortcut for platform-owned apps; if a later internal app needs data the public API does not expose, the answer is a new public endpoint with a scope, not an op. Follow-ups (outside #164's files): the public edge should not route node endpoints at all (defence in depth over the node-token check), and a controlled worker should refuse to start when [control] url resolves to an address tenant fetch would allow; deploy-api.md Open item 5 closes with a pointer here at the next contract edit. Alternatives considered: (b) a ctx.platform op for tenants flagged internal: adds a privileged op to every isolate's surface, makes the route pipeline a source of privilege (a wrong internal bit is a cross-tenant breach), and needs coordinated changes of route_entry, ops.md and ctx.d.ts; its gain is latency. A per-node exemption from fetch_blocked_addresses for a dedicated internal cell: an exemption by configuration that a mis-assigned tenant inherits, and it opens the node-facing endpoints too. Evidence: #164, #57, #37 (gate F1 notes), docs/contracts/deploy-api.md sections 2 and 7 and Open item 5, docs/24-threat-model.md B ("SSRF to metadata, loopback, private ranges"), crates/renox-runtime/tests/internal_control_api.rs (a normal tenant gets RENOX_FETCH_BLOCKED on the node-facing control plane; the dashboard tenant reaches the public API with a scoped token).
D24 — An in-house design system for Renoxium's own pages
Status: accepted Date: 2026-11-01 Context: The dashboard (control/dashboard) and the site (www) were styled by two small hand-written stylesheets with no shared vocabulary, and the owner found the result too plain. docs/19-platform-wireframes.md fixes information architecture, not visual design. Any styling must hold to constraints that already bind these apps: no build step (they are Renox apps, and "no build step, no Node" is the promise to tenants), the CSP every app gets (default-src 'self', so nothing from a CDN and no inline style attributes), server-rendered HTML with htmx fragments (D9), and Arabic pages served right to left (#336). Candidates checked on 2026-11-01 against npm and the projects' own notices: Pico CSS fit best on paper but was archived on 2026-10-04 (last release 2.1.1, March 2025); Tailwind and daisyUI need a CSS build, and classes added by htmx fragments can be purged; shadcn/ui is React only; Web Awesome renders in the browser, against D7 and D9; Bootstrap 5.3 and Tabler (built on it) are maintained and support right to left, but are large and look generic; Basecoat and Franken UI are young or slower to release and depend on the Tailwind or UIkit ecosystems. The components the two apps need number about fifteen. Decision: Renoxium's own pages use packages/ui/renox-ui.css, written in house. It is plain CSS: design tokens (--rx-*), a base, layout, components and utilities; system font stacks with Latin and Arabic faces; only logical properties; light and dark through prefers-color-scheme; nothing loaded from anywhere (url(), @import). The visual idea comes from the product: a node packed with tenant cells, most idle, a few lit. The lit cell (jade) is the only accent and means running, current or the action to take; the brand mark, the site's hero and live status use it. Cool porcelain ground, blue-black ink, one sans family with monospace only for code, sentence-case labels, radius by rank (cell, control, panel). A first draft (cream paper, serif headlines, vermilion, hairline rules, all-caps labels, monospace captions) was dropped on review because each of those is a common default of generated pages rather than a choice made for Renoxium. Each app copies the file into its public/ folder unchanged and adds a thin layer of its own after it (control/dashboard/public/app.css, www/public/site.css). Each app's tests check that the copy equals the source and that the app's stylesheets use no physical direction and load nothing (packages/ui/lint.ts). Consequences: Easier: no dependency to upgrade or to lose; the stylesheet stays small and fits the CSP and the no-build rule; the Arabic pages mirror without overrides; a distinct look. Harder: visual design, accessibility (contrast, focus, motion) and browser quirks are ours to get right, with no upstream fixes; every component is built when first needed; the copies must be refreshed by hand (the tests catch drift). Not decided here: whether modules (modules/*) and tenant apps get this stylesheet. Offering it to tenants makes it a compatibility surface (class names and tokens become a contract), so that needs its own decision. Alternatives considered: Pico CSS (archived, no fixes). Tailwind or daisyUI (a build step for the platform's own apps contradicts the no-build promise; purge risk with htmx). shadcn/ui (React). Web Awesome (client-rendered components; no published CSP stance). Bootstrap 5.3 or Tabler (viable; rejected for size, the generic look, and a third-party dependency that would pass to tenants through modules). Basecoat, Franken UI (young or slower releases; Tailwind or UIkit underneath). Evidence: packages/ui/README.md; control/dashboard/tests/ui.test.ts; www/tests/pages.test.ts ("design system"); Pico CSS maintenance notice.