OpenInspection is a home inspection app deployed as a single Cloudflare Worker (the cloudflare/react-router-hono-fullstack-template shape): a Hono entry that mounts the full API in-process and delegates page routes to React Router v8 SSR. This doc covers the high-level architecture for self-hosters, contributors, and reviewers.
Self-hosted instances run standalone (single-tenant) — the default in
wrangler.jsonc. The engine is tenant-aware under the hood (every table carriestenant_id), but you don't manage tenants or subdomains; one fixed tenant holds all your data.
| Layer | Tech |
|---|---|
| Edge runtime | Cloudflare Workers (Free tier sufficient for solo inspectors) |
| API routing | Hono + Zod OpenAPI (typed JSON API) |
| Frontend | React Router v8 + React 19 + Vite |
| ORM + DB | Drizzle + Cloudflare D1 (SQLite) |
| Object storage | Cloudflare R2 (photos, future PDFs) |
| KV cache | Cloudflare Workers KV (tenant config, signed tokens, rate-limit counters) |
| Background jobs | Cloudflare Workflow (onboarding, sign-completion) + Cron Triggers (automation sweeps) |
| PDF rendering | Cloudflare Browser Run — env.BROWSER.quickAction("pdf", { url }) (free tier, 10 min/day) |
| E-signatures | Ed25519 per-tenant keypair + SHA-256 hash-chained audit log (ESIGN Act + UETA) |
| Styling | Tailwind CSS v4 + Design System 0523 tokens |
| Shared components | packages/shared-ui/ — token-based React components (src/index.ts is the list) |
| AI | Any OpenAI-compatible endpoint (optional) — see ../integrations/ai.md |
| Resend, SendGrid, Postmark or Mailgun (HTTP APIs only, no SMTP) | |
| Payments | Stripe Connect (optional) |
| Auth | ES256 JWT in HttpOnly cookie + PBKDF2-SHA256 password hashing |
OpenInspection runs as ONE Cloudflare Worker. workers/app.ts is a Hono app that is the worker entry: it mounts the full API (server/) for API-owned paths and delegates everything else (page routes) to the React Router v8 SSR handler.
┌──────────────────────────────────────────┐
Browser ────────► │ Single Worker (workers/app.ts) │
│ Hono entry │
│ │
│ ┌──────────────────┐ ┌────────────────┐ │
│ │ /api/*, /status, │ │ everything else│ │
│ │ /sign/*, … → │ │ → React Router │ │
│ │ API app (server/)│ │ v8 SSR (app/) │ │
│ │ in-process │◄─┤ in-process │ │
│ │ Hono+Drizzle+D1 │ │ API_WORKER │ │
│ └──────────────────┘ └────────────────┘ │
│ │
│ D1 · R2 · KV · Workflow · Durable Objects │
└──────────────────────────────────────────┘
workers/app.ts— a Hono app is the worker entry. It routes API-owned paths (/api/*,/status,/m2m/*,/photos/*,/.well-known/*,/doc,/sso,/sign/*,/webhooks/*,/agent/magic-login, the ICS feed) to the API app and sends everything else to the React Router v8 SSR handler./mcpand/mcp/{slug}are deliberately absent from that list: when MCP is enabled the OAuth provider wrapper owns that prefix and the request never reaches this router; when it is off the path falls through to the SSR 404. It injects an in-processAPI_WORKERself-binding so React Router loaders/actions call the API app DIRECTLY (no network hop, no second worker, no Service Binding between workers).server/— Hono + Drizzle + D1. Handles all business logic, authentication, and data access. Exposes a typed JSON API.app/— React Router v8 + React 19 + Tailwind v4. Server-side renders the React UI on the edge.- Shared UI (
packages/shared-ui/) — Design System 0523 token-based React components (Button, Pill, Card, etc.).packages/shared-ui/src/index.tsis the authoritative list;design-system.mddescribes each one. - API Types (
packages/api-types/) — Re-exports the Hono app type so the web layer'shono/clientgets full end-to-end type safety.
The web layer uses a Token Relay BFF pattern: the React Router v8 server holds the JWT cookie and forwards it to the in-process API on every request, so the browser never sees the token directly.
- SPA navigation: page transitions without full reload — inspectors switch between editor/dashboard/templates frequently
- React 19: future React Native app can reuse hooks and state logic (useInspection, useFindings, useSync)
- SSR on Workers: full server rendering at the edge, same latency as static HTML
- hono/client: Hono exports
AppType, React Router v8 useshono/clientfor compile-time type-safe API calls — zero handwritten API client ⚠️ CPU per request is NOT ~1-3ms. This line used to read "CF Free Tier safe: React Router v8 SSR adds ~1-3ms CPU per request, well within 10ms limit". Measured against a live deployment on 2026-09-05, over seven days of real traffic: p50 8ms, p95 49ms, p99 106ms, max 494ms. A statutory-form render is ~470ms in workerd on its own. The old figure was an SSR microcost quoted as if it were the request, and it was never re-measured. A plan capping CPU in the low milliseconds will kill ordinary page loads — observed, as HTTP 1102, on a plain settings page.
The same arithmetic has to be done for the scheduled path, and for a long time it was not. The Workers Free CPU ceiling is 10 ms per invocation, and it applies to a cron invocation exactly as it applies to a request. The line above was written about
fetchand nothing ever wrote the equivalent forscheduled, which ran thirteen background jobs serially in one invocation — measured at 138 ms, 13.8x the ceiling, with ticks being killed outright. See Background work for the shape that replaced it.
apps/openinspection/
├── workers/
│ └── app.ts # Single-worker entry: Hono mounts API in-process + delegates pages to RR SSR
├── server/ # API (Hono + Drizzle + D1)
│ ├── index.ts # Hono app entry, middleware order, route registration
│ ├── api/ # Route handlers (one file per resource)
│ │ ├── auth.ts # /api/auth/{login,register,reset-password,...}
│ │ ├── inspections.ts # /api/inspections/* + share/print
│ │ ├── ai.ts # /api/ai/{suggest-comment,comment/edit}
│ │ ├── bookings.ts # /api/public/* (no auth) — booking page data + submit
│ │ └── ...
│ ├── services/ # Business logic, DB queries (Drizzle)
│ ├── features/ # Feature-scoped modules
│ │ └── tenant-routing/ # Tenant resolution (standalone pins one fixed tenant)
│ ├── lib/
│ │ ├── middleware/ # Hono middleware (auth, RBAC, branding, DI)
│ │ ├── db/ # Drizzle schema + utils
│ │ ├── validations/ # Zod schemas per module
│ │ ├── errors.ts # AppError + ErrorCode + Errors factory
│ │ ├── logger.ts # Structured JSON logger (use this, not console)
│ │ └── ics.ts # iCalendar string builder
│ ├── workflows/ # Cloudflare Workflow durable steps (e-sign completion)
│ └── portal/ # SaaS-only portal integration (unused in standalone)
├── app/ # Web (React Router v8 + React 19 + Tailwind v4)
│ ├── root.tsx # React Router v8 root layout
│ ├── routes.ts # Route configuration
│ ├── entry.server.tsx # React Router v8 CF Workers entry
│ ├── routes/ # Route files (loader + action + component)
│ ├── components/ # React components
│ ├── hooks/ # React hooks
│ ├── lib/ # API client (hono/client over the in-process binding), session, helpers
│ └── styles/tailwind.css # Design System 0523 token layer
├── migrations/ # D1 SQL migrations (drizzle-kit schema-first: one regenerated 0000_baseline.sql, forward files on top)
├── tests/ # unit / workers / contract / e2e — see develop/testing.md
│ # (web unit specs are CO-LOCATED under app/; tests/web/ is retired)
├── packages/
│ ├── shared-ui/src/ # shared React components
│ └── api-types/ # CoreApiType for hono/client
├── scripts/ # Setup, seed, backup, deploy helpers
└── wrangler.jsonc # Single-worker config + bindings (committed, placeholder IDs; real IDs in gitignored wrangler.local.jsonc / wrangler.saas.jsonc)
Browser request
↓
Cloudflare edge → single Worker (workers/app.ts) → Hono routes non-API paths to RR SSR
↓
React Router v8 server (SSR):
1. Route matched → loader() or action() executes
2. Reads session cookie (Token Relay BFF)
3. Calls the in-process API via the injected API_WORKER binding (hono/client) — no network hop
4. Renders React component tree to HTML
↓
HTML + hydration bundle sent to browser
↓
Client-side: React hydrates, subsequent navigations use client-side routing
API request (from an RR loader/action via the in-process API_WORKER binding, or direct over HTTP)
↓
Cloudflare edge → single Worker (workers/app.ts) → Hono routes API-owned paths to the API app
↓
Hono middleware stack, in the order `server/index.ts` registers it
(pinned by tests/unit/platform/middleware-order.spec.ts):
1. CSP / security headers
2. Context bootstrap
3. Tenant router (standalone: pins the one fixed tenant)
4. Branding resolver (KV → D1 fallback)
5. Tenant-active guard
6. JWT auth (skipped on the public prefixes)
7. Agent terms gate
8. Idempotency guard (mutating routes)
9. Integration secrets (merges the tenant's decrypted keys into a COPY of env)
10. DI proxy (lazy-instantiates services)
11. Inspector palette
12. Last-active touch (/api/* only)
↓
Bot protection (Turnstile) is NOT in this stack — it is applied per route, on
the two surfaces an anonymous visitor can submit to. See
[`../integrations/turnstile.md`](../integrations/turnstile.md).
↓
Route handler reads validated input via c.req.valid('json')
↓
Handler calls c.var.services.xxx (auto-tenant-scoped)
↓
Service queries D1 (Drizzle) / R2 / KV / external API
↓
Response via sendSuccess() / sendError() (canonical envelope)
Every D1 table includes tenant_id (NOT NULL) so the data model is tenant-aware, but a
self-hosted instance runs standalone: SINGLE_TENANT_ID pins all data to one fixed
tenant. You never manage tenants or subdomains. Tenant resolution lives in
server/features/tenant-routing/; in standalone the tenantRouter middleware simply pins
the request to profile.fixedTenantId (resolve-by-fixed-tenant.ts).
A SaaS overlay (
server/portal/, active only whenAPP_MODE=saas) lets a multi-tenant deployment hand tenant records, seats and credentials to an external control plane over the machine-to-machine seam described inreference/api.md. Standalone builds execute none of it:hasPortalIntegrationApiis false, so/api/platform/*is not mounted at all. The code is here and readable; what runs on the other end of that seam is not part of this repository.
Mode-dependent behaviour comes from server/lib/deployment-profile.ts — the one
place that reads env.APP_MODE. Read a capability; never branch on the mode
yourself. Three sanctioned readers, by what you are holding:
| You hold | You read |
|---|---|
| a Hono handler / middleware | c.var.profile.<capability> |
| only an env (RR loader or action, cron, Workflow, queue consumer) | getDeploymentProfile(env).<capability> |
| a client component | isSaas / deployment.mode from the session context |
getDeploymentProfile takes ProfileEnv (the three fields it reads), not
AppEnv, so every env shape in the worker satisfies it with no cast.
tests/unit/sync/portal-isolation.spec.ts enforces the rule.
- Login → server signs JWT (ES256 with
kidheader, includesiatclaim) → sets__Host-inspector_tokenHttpOnly cookie - Each request: middleware verifies JWT signature + checks
iat >= KV[pwchanged:userId] - Password change: writes
pwchanged:userId = now()to KV → invalidates all prior tokens server-side - Browser JS never sees the token (HttpOnly enforced); same-origin
fetch()sends the cookie automatically. - The web layer uses Token Relay BFF: the React Router v8 server reads the cookie and forwards it to the in-process API on every request.
Per-tenant Ed25519 keypair generated on first use (SigningKeyService.ensureKeypair). The private key is AES-GCM encrypted with KEY_ENCRYPTION_SECRET and stored in D1; the public key is exposed unauthenticated at /.well-known/openinspection/tenant-keys/:slug (1-hour cache) so any third party can verify signatures independently.
Each signature event appends a row to esign_audit_logs whose prev_hash = SHA-256 of the canonical JSON of the previous row. Editing any row invalidates the chain from that point onward — detectable by re-deriving hashes. The chain is signed with the tenant's Ed25519 private key at every append, not just at sign time.
Customer signs at /agreements/sign/:tenant/:token → API writes an agreement.signed audit row, generates a verificationToken, and fires SignCompletionWorkflow asynchronously. The synchronous response to the customer is immediate; PDF generation happens in the background.
- Render
signed.pdfviaenv.BROWSER.quickAction("pdf", { url })(Browser Run) → store in R2. - Render
certificate.pdfthe same way → store in R2. - Assemble
evidence.zip(signed.pdf + certificate.pdf + audit-log JSON). - Append
workflow.completeaudit row recording the SHA-256 hashes of all three artifacts. - Email the client via Resend with
signed.pdfandevidence.zipas attachments.
Browser Run requires compatibility_date >= "2026-03-24" in wrangler.jsonc and uses the free tier (10 browser-minutes/day — sufficient for typical inspection volume). Admin download endpoints for signed.pdf, certificate.pdf, and evidence.zip are Worker-proxied from R2.
Correction, and what the three downloads say about themselves. A published report is corrected by AMENDMENT, never in place: correctReport (server/services/report-correction.service.ts) applies the change to the record and publishes version N+1 through the same snapshot path, so the delivered version stays byte-identical and its hash chain goes on verifying. The three downloads above are part of the same graph rather than a separate one — they are files the product actively serves, not an archive or a backup, so each 200 carries x-artifact-status: current | superseded, resolved from the amendment ledger by server/lib/artifact-status.ts. A superseded artefact is still retrievable as historical evidence; what changes is that it no longer claims to be the current answer.
The cache directives are part of that claim rather than a separate concern. A status header describes right now, so current responses are sent private, no-cache, must-revalidate and superseded ones private, no-store. The private, max-age=300 these endpoints used to send let a copy fetched shortly before a correction go on claiming current for five minutes afterwards inside the client's own cache.
- Public verifier (
/v/:verificationToken): SSR page resolves the token to an envelope, runs a server-side audit-chain integrity check and Ed25519 signature check, and displays the result with download links. QR code on signed.pdf and certificate.pdf points here. - Offline self-verify (
/verify): accepts anevidence.zipupload and re-runs SHA-256 chain re-derivation + Ed25519 signature verification entirely in the browser via Web Crypto API — no server involvement, court-friendly independence from the operator.
- D1 — Inspector pre-sign: inspector can sign the agreement before sending to the client via
POST /api/admin/agreement-requests/:id/inspector-sign. The render handler conditionally adds an inspector signature block when present. - D2 — Auto-sign on publish: per-inspection
auto_sign_on_publishflag (plus a tenant-level default). When an inspector has a savedusers.default_signature_base64and the flag is set,InspectionService.publishInspectionauto-injects the inspector's signature intoinspection_results.dataat publish time. The report viewer and print output render the signature block automatically.
A rendered signature image may be persisted for execution and evidence purposes. A reusable biometric or behavioural signature template may not be persisted or derived — not stroke geometry, not pen pressure, not timing.
This is not a hypothetical boundary. The pad already samples pointer pressure
and coalesced events into StrokePoint { x, y, p } while you draw. Its handle
exposes only toDataURL, isEmpty and clear, so today the invariant holds
because one accessor does not exist — which is one refactor from being untrue.
Two properties make it enforceable rather than aspirational:
- The exemption is a directory,
app/components/media-studio/. That is where the data is legitimately handled and where it stops. An allowlist of file names would grow one entry at a time until it described nothing. - The gate scans for the stroke symbols and field shapes, not for column
definitions. The inspector signature is written into
inspection_results.dataas_inspector_signature, which is a JSON blob — a stroke payload could ride there with no schema change at all, and a column grep would never see it.
Enforced by npm run lint:signature-dynamics
(scripts/check-signature-dynamics.mjs), in the lint chain and
lint:gates-full. Not to be confused with lint:sigcompare, which enforces the
opposite duty on cryptographic verification: that it goes through
crypto.subtle.verify or a constant-time compare.
The rule above is about the input — how the mark was made. The second half is about the use: a signature image is never used or stored for biometric authentication. A pad that captures nothing but a picture still crosses the line the moment that picture is matched against a stored one to decide who somebody is.
It lives in the same gate rather than beside it. The statutory test turns on the words "used to authenticate", so the two halves are one boundary seen from two angles, and two separate gates could each pass while it broke between them.
Three shapes are banned, and they are the three stages of every biometric pipeline:
| stage | banned shape | example |
|---|---|---|
| feature extraction | turning the image into a key | extractSignatureImageFeatures |
| enrolment | storing a template of the image | signatureImageTemplate |
| comparison | matching two images to decide identity | compareSignatureImages |
Hashing the image is the approved alternative and is deliberately untouched.
signatureImageHash — a SHA-256 fingerprint recorded at signing — proves the
stored record is unaltered and identifies nobody. Every pattern requires either
the word Image or an unambiguous biometric noun, so a hash never reaches them,
and neither do the cryptographic verify*Signature functions that
lint:sigcompare requires.
Two design notes that were bought with a mutation proof rather than reasoning:
- The patterns carry no leading word boundary. A planted
loadSignatureImageTemplate(...)passed silently the first time, because a word boundary cannot match betweenloadandSignature; any verb prefix walked straight through. The end of the identifier is what carries the meaning, so only the trailing boundary is kept. - No prefix is exempted,
emailincluded. An email-signature template that happened to hold an image would trip this and should be renamed, or argued about in the open. That is a far cheaper failure than a biometric template hidden behind a prefix somebody once added to an exemption list.
npm run lint:verification-copy (scripts/check-verification-copy.mjs) is the
copy-side companion, and it is a Global Core control —
Verification Claim Integrity, not a regional overlay: it is load-bearing under
FTC Act §5, state UDAP statutes, contract expectation and evidentiary integrity
at once. It scans every message catalogue, in every locale, for copy that
converts an integrity result into a conclusion about human authorship, identity,
intent, consent or legal validity — "Signature Verified", "Valid Signature",
"Signer Verified", "Signed by [person]" and the rest. A disclaimer does not
rescue an over-broad claim; narrow the claim. What is permitted is the claim
narrowed to the check that ran: "The stored signature image matches the
signature image fingerprint recorded at signing."
Each domain has a service class with:
- Constructor receiving
db(orScopedDB) +tenantId - Methods that filter by
tenant_idautomatically (viaScopedDBwrapper) - No direct DB calls in route handlers — always via service
Example:
// In a route handler:
const inspections = await c.var.services.inspection.list({ status: 'in_progress' });
// c.var.services.inspection is auto-instantiated with c.get('tenantId')The DI proxy in server/lib/middleware/di.ts lazy-instantiates each service on first access per request.
- React Router v8 SSR: Routes in
app/routes/useloader()for data fetching andaction()for mutations. Full server-side rendering on Cloudflare Workers. - React components:
app/components/, organized by domain (inspection, template, booking, media-studio, portal, …). Counts are not written here because they go stale between releases;ls app/componentsis the answer. - Hooks:
app/hooks/holds the state hooks the editor is built from —useInspection,useFindings,useKeyboard(shortcuts),useCannedComments,usePresence(WebSocket),useTheme,useUnsavedChanges,useSessionContext, and thefindings/andinspection/sub-hook directories. - Design tokens: Tailwind v4 with Design System 0523 tokens in
app/styles/tailwind.css. - Shared UI:
packages/shared-ui/provides the design-system components (Button, Pill, Card, …) consumed by the frontend; itssrc/index.tsis the list. - Dark mode:
data-color-schemeattribute on<html>, managed byuseThemehook (auto/light/dark).
- PWA (current) — installable; the Service Worker caches the shell, and
the field data is a Yjs CRDT document buffered in IndexedDB (
y-indexeddb), with photo bytes held inapp/lib/collab/media-pending-store.tsuntil they drain to R2 — seecollab-editing.md. - A capture-first native client (decided 2026-09-07) — capture is its
primary job. Photos, findings and voice notes are written to device storage
at the moment of capture, with no round trip to the server; sync happens
afterwards. Report editing, templates and admin stay on the web surface.
Local storage, sync ordering, de-duplication and conflict resolution are the
architecture of this client, not features added to it later — and they are
already decided: the native client speaks the same Yjs document to the same
Durable Object. Its open question is the runtime, because that picks the
Yjs binding and the local doc store — JavaScript keeps
yjsand swapsy-indexeddbfor a device-storage adapter; Swift or Kotlin means theyrsbindings. Nothing about ordering, de-duplication or conflicts is redesigned.
Capacitor was on this list and is not any more (2026-08-18). It was a WebView wrapper with native camera and offline capture deferred to later work, and those deferred items are the actual requirement — an inspector works basements and crawlspaces with no usable connection. That is a judgement about what this product needs, not about the tool. Until the capture-first client exists, responsive web is the field surface.
Why it is built early rather than after the web app matures: two requirements decide it — capture where there is no signal, and offline sync that lands what was captured in order, without duplicates, resolving conflicts when two devices or a web session touched the same inspection. Both are structural. They are cheap to design in from the start and expensive to retrofit into a browser-first codebase. The collab layer already gives the web editor the second one — CRDT merge, last-writer-wins per scalar field, arrays keyed by id, a version history for the value that lost — and the pending-media store gives it the first for photos. The capture-first client makes that offline path the primary one instead of the fallback. Were those two requirements not real, responsive web would stay the field surface and this client could wait.
Two pages author the same shape. /templates/:id/edit writes a template's
schema; /inspections/:id/edit fills in results against a snapshot of that
schema. The two route trees and their state hooks are deliberately not
merged — one changes a schema, the other changes results, and that is a real
difference. What they share is the component set and the data shapes.
For a while there were two of everything. One SectionRail addressed sections
by array index and could only reorder them; a second addressed them by id and
had grown progress rings, structural editing and save-to-template. The
inspection-side copy was by then a strict superset of the authoring-side one,
which is the tell: the fork was not expressing a difference, it was losing
fixes on whichever copy the next change did not touch.
The structural components now exist once, under
app/components/editor-shared/, and take mode: 'author' | 'fill'
(editor-shared/editor-mode.ts):
| Component | author renders |
fill renders |
|---|---|---|
SectionRail |
item counts | progress donut, defect count, the Inspection Details overview entry |
ItemList |
item-type badges | rating state, batch selection |
SideRail |
Preview / Library | Preview / Library / Photos |
SideRail is the exception to the directory: it still lives at
app/components/editor/SideRail.tsx and imports EditorMode from
editor-shared/. It is mode-aware but has not moved.
The item detail panel is the one thing that stays forked, and it says why.
Configuring an item's type and its canned entries
(components/template/ItemPropertiesPanel.tsx, ItemCommentsPanel.tsx) and
rating one in the field (components/editor/ItemEditor.tsx) are different jobs,
not one job with a flag. They share the atoms instead of the panel —
ItemHeader, CannedCommentRow, DefectCategoryChip, InlineRename,
RatingSegment, all under editor-shared/.
components/template/preview-parity.test.ts renders one canned entry through
both the template preview and the editor's SideRail and compares what a reader
sees, so the two cannot drift in silence.
Five things were retired to get here, and none of them should come back:
- The duplicate
SectionRailandItemList. One of each, addressed by id. - A second inspection-filling route,
/inspections/:id/form, with its own renderer. It was an orphan — registered inroutes.ts, linked from nowhere — and it was also the only place the eight non-richitem types anditem.descriptionwere ever rendered, so the live editor was the one missing them. Both now render inItemEditor; the route and its renderer are gone.FormField(app/components/form/FormField.tsx) survives as the building blockItemEditorreuses for non-richinput, which writes toresult.value.item.descriptionis inspector-facing guidance: it shows in the editor and is never emitted to the report or to the client. - Per-section authored applicability. A right-hand rail let a template
author declare which property types and commercial subtypes each section
applied to. It drove a preview and nothing else — the resolver never acquired
a production caller. Templates now identify by property type instead, which
picks the seed content and gates the commercial report block.
server/lib/section-applicability.tsis frozen dead code that says so at the top: two unit specs still exercise it directly, andapplicableTostays in the template JSON schema to avoid OpenAPI-snapshot churn, round-tripping untouched throughapp/lib/editor/template-meta.ts. Do not re-wire it into an editor. - The second rating-system editor. A modal in the template editor authored
one level shape while the library page authored another, bridged one way, and
pausesAdvancecould not be edited from either. There is now oneRatingSystemEditor(app/components/RatingSystemEditor.tsx) and one canonical level shape —{ id, label, abbreviation, color, severity, isDefect, pausesAdvance?, hotkey?, order }. - Three different reorder gestures for the same operation (drag here, arrow
buttons there, a dropdown menu somewhere else). Dragging is the gesture, via
editor-shared/useSortableReorder.ts, with the arrow/menu path kept as the keyboard-reachable fallback rather than as a second way of doing it.
mode was the first dimension and is not the only one. The shared interfaces
reserve two more, and a feature that touches the editor is expected to extend
one of them rather than grow a parallel tree:
- Unit / scope.
SectionRailandItemListtakeactiveUnitId, which chooses whose results are being read; the finding key is{unitId}:{sectionId}:{itemId}. A per-unit inspection is this editor with a scope selected, not a second editor. The unit tree (inspection_units, building → floor → unit, at most three levels) is opt-in, and it is what makes a site with several structures one inspection. Expressing the unit axis by duplicating a section per unit was rejected: that puts it in the template, where it has to be re-authored for every property. - Location. A tagged inspection walks the checklist once and labels each
finding with a location drawn from
inspections.location_options— a flat list, because what a tagged walk needs is a label and not a hierarchy. Modelling it as a tree would make the lightest mode sit on the heaviest data structure for no gain.
inspections.unit_inspection_mode (tagged | per_unit) picks between them, and
it is a per-inspection setting rather than a live toggle, because it decides
the shape of the whole checklist: tagged is one pass with location labels,
per_unit is a complete sub-inspection per unit.
server/services/unit-switch.service.ts converts in both directions —
promoting flat location labels into single-level unit nodes going up, merging
unit labels back into the picklist coming down.
Building a bespoke authoring surface for a new deliverable has been tried here
and retired; the applicability rail above is that attempt. Commercial PCA work
therefore rides these same two editors, with a commercial property type as the
template's identity, and adds itself as a dimension.
../concepts/commercial-pca-report.md
states the same rule from the feature's side.
lint:* entry for it, and no test
asserts that a second SectionRail has not appeared. It is held by review and
by this page.
A finding carries two independent classifications, and collapsing them into one is the recurring mistake.
severity is how bad it is. One vocabulary, four words —
good | marginal | significant | minor — declared in app/lib/severity.ts and
in server/lib/validations/rating-system.schema.ts. A rating level carries it;
a canned comment carries it (comments.severity); the editor's library filter
matches the two structurally, by reading the active level's own severity
field (severityForRatingId in app/hooks/useCannedComments.ts).
Two details of that are worth keeping. The vocabulary used to be two vocabularies for one axis — rating levels said
severity, comments said a locally coinedratingBucket(satisfactory | monitor | defect | na), and a fixed table bridged them. And the matching used to GUESS the severity from the level's name, so a workspace that named its levels unconventionally, or named them in a language other than English, got no matches and was told nothing. Reading the field works for any custom rating system in any language.ratingBucketand the name-guessing helper are both gone.
A defect category is what kind of thing it is — whether it belongs in the
report Summary, and who gets dispatched. Categories are tenant rows in
defect_categories (server/lib/db/schema/inspection/defect-category.ts)
carrying only name, color, drivesSummary, sortOrder and isSeed, seeded
with three and managed at /library/defect-categories. They were a hard-coded
three-value enum, which was a guess about how every inspector files work.
The two axes deliberately do not map onto each other. A category has no
severity, and the comment library filters on severity alone. "Which severity
is a Safety category?" is exactly the question this split exists to refuse.
DefectCategoryisstring, not an id type.defectDrivesSummary(server/services/inspection/inspection-report.service.ts) resolves it by id or name, because seed template JSON stores a name ("safety") while anything authored after the tenant table stores adefect_categories.id. An unknown or absent category resolves todrivesSummary: true: a finding nobody classified still reaches the Summary rather than quietly falling out of it.mapRatingSystemLevels(server/lib/map-rating-levels.ts) is the only level shape the editor ever sees, so a field dropped in that mapper does not exist as far as the UI is concerned.pausesAdvancewas dropped there once and silently killed the seeds' "stop on a defect so notes actually get written" behaviour, with nothing red anywhere. An unrecognisedseverityfalls back tominorrather than throwing, so one stale row cannot take a report down.activeUnitIddefaults tonull, which resolves the_defaultcommon scope. Omitting it and passingnullhave to stay identical, or every finding key shifts.
- D1: structured data (tenants, users, inspections, templates, comments, agreements, audit logs, ...)
- R2: blobs. One bucket binding,
PHOTOS— field photos, logos, pre-rendered report and certificate PDFs, and e-sign evidence packs all live in it. There is noREPORTSbucket; this document named one for a long time andwrangler.jsonchas never bound it. Accessed via signed URL or a Worker pass-through endpoint. (A SaaS deployment adds one more,EXPORTS_BUCKET, shared with the control plane for offboarding exports.) - KV: two namespaces.
TENANT_CACHEholds short-lived signed tokens (agent share, password reset), the tenant config and branding caches, cron cursors and rate-limit counters — see../concepts/kv-cache.md.OAUTH_KVis owned by@cloudflare/workers-oauth-providerand holds MCP OAuth grants; its binding name is fixed by that library. - Queues:
CRON_QUEUE(one background job per invocation — see Background work) andWORD_EXPORT_QUEUE(async.docxexport). Both are declared inwrangler.jsonc, producer and consumer, because neither is a SaaS-only feature. - Durable Objects:
INSPECTION_PRESENCEandTENANT_PRESENCE(live presence),INSPECTION_DOC(the collaborative results document),INSPECTOR_MCP(the remote MCP server).
-
Sign-completion workflow (
server/workflows/): renders the signed PDF + Certificate of Completion, assembles the evidence pack, and emails the client — see E-signature. Cloudflare Workflow guarantees retries and persistence across Worker restarts. -
Cron triggers: three expressions, declared identically in
wrangler.jsonc(standalone) andwrangler.saas.jsonc.Expression Carries */5 * * * *The main tick. It probes and enqueues only — see below. 0 3 * * *Daily R2 usage measurement ( r2-usage).0 4 * * *Daily log-table retention sweep ( retention-logs) and the statutory-form revision watch (statutory-revision-watch).Cron Triggers are limited to 5 per account on the Free plan, which is the budget these three are spent from.
The Workers Free CPU ceiling is 10 ms per invocation, for cron invocations as much as for
requests. Thirteen background jobs therefore cannot share one scheduled() call however fast
each of them is — the only fix is more invocations, not faster jobs.
So the tick does not run jobs. server/cron/registry.ts declares each job with a cheap
probe() (is there work? — at most a LIMIT 1 on the job's own due-predicate) and a bounded
run(cursor). server/cron/dispatch.ts probes the jobs belonging to the expression that
fired and enqueues one message per job that has work; server/cron/consumer.ts runs exactly
one job per queue message, so each job gets its own invocation with its own budget. A job
with more work than one batch re-enqueues itself with a cursor rather than looping, because a
loop would put the whole sweep back inside a single invocation and undo the split.
Three properties are load-bearing, and each is held by something executable rather than by this paragraph:
- No job body may run on the cron invocation.
tests/unit/tooling/cron-dispatch.spec.tsasserts it;npm run lint:cron-budgetgates it. - Every job carries a
maxBatch, and no cron path holds an unbounded table read. Gated by the same script, which prints what it checked next to what it found on every run and treats "checked nothing" as a failure. - Probe-then-enqueue, not enqueue-always. The other Free ceiling is Queues at 10,000
operations/day, shared with the Word-export queue. The five-minute tick fires 288 times a
day and every message costs two operations (the send and the read), so enqueueing each
tick-owned job unconditionally would spend roughly
jobs × 576of that budget a day on jobs with nothing to do — 6,912 at the twelve currently on the tick, and the count grows with the registry. A single-inspector deployment's ticks are almost all empty, so probing first sends almost nothing.
Cursors live in Workers KV (cron:cursor:<job>), not in a column — they are bookkeeping, every
paged job is idempotent, and a column would mean a migration in both deployment modes.
maxBatch down until every invocation sits at or
under 5 ms — half the ceiling, so growth does not immediately re-break it — before treating
the free-tier claim as re-established.
| Resource | Free limit | Typical inspector usage |
|---|---|---|
| Worker requests | 100k/day | < 1k/day for solo inspector |
| D1 reads | 5M/day | < 100/inspection |
| D1 writes | 100k/day | < 50/inspection |
| R2 storage | 10 GB | ~ 50 MB/inspection |
| R2 Class A ops | 1M/mo | photo writes — < 100/inspection |
| KV reads | 100k/day | < 10/request avg |
| Workflows | 100k/day | one per booking |
A solo inspector doing 50 inspections/month uses approximately 1-2% of Free tier limits. Browser Run (server-side PDF generation) is included on the Free tier with 10 browser-minutes/day — sufficient for typical inspection volume. Wrangler compatibility_date >= "2026-03-24" is required to enable the .quickAction() API.
| Resource | Free limit | Worker bundle max |
|---|---|---|
| CPU time | 10ms/request | — |
| Worker bundle | — | 3 MB gzip |
| D1 reads | 5M/day | — |
| R2 storage | 10 GB | — |
The combined Worker bundle stays within the size limit. Browser Run (server-side
PDF) is on the Free tier (10 min/day); it requires compatibility_date >= "2026-03-24" and the browser binding in wrangler.jsonc.