Destination
A walking skeleton on the chosen stack. apps/server runs sign up, sign in, and invite through better-auth, plus one waitlist route. apps/web at app.prismark.tech and apps/mobile (against any instance) consume it through the chosen contract. apps/landing is live at prismark.tech with the waitlist page. Unit, integration, and web end to end tests are green in CI, and every app deploys through Cloudflare with a Deploy to Cloudflare button in the README. Nothing past sign in and the waitlist is built.
Notes
- Execute as we go. Tickets scaffold real code. A decision is written as an ADR in
docs/adr/ only once the skeleton proves it, by the ticket that proved it.
- Glossary and ADRs.
CONTEXT.md is the vocabulary; use it in every title, test name, and comment. Sign up, Invite, and Sign in were redefined while charting; Waitlist entry was added by the transport ticket. ADR 0007 (Cloudflare for everything) and ADR 0008 (latest everything) stand and bind every ticket.
- Library versions. oRPC v2 beta only, never v1, and never answer from memory about it: read the current docs at orpc.dev and the
middleapi/orpc source. Same for Zod 4, better-auth, Drizzle, and Cloudflare's Vitest plugin. Effect is not in the stack; anything proposing it is working from the old map.
- Skills. Decision tickets call
grilling and domain-modeling. Building tickets call tdd and codebase-design. Research tickets call research.
- What the transport must optimise for, in order. End to end types with no codegen; typed errors and dependency injection inside the server; TanStack Query on both clients; an OpenAPI spec; learning curve for one maintainer; bundle size.
- One server author, coming from Rust. Typed errors matter to them; the transport ticket chose to get them from oRPC's error classes at the boundary and to add a Result library in the interior only when a service earns it.
- Bindings come from
cloudflare:workers. Current Workers code reads env at module scope: import { env } from "cloudflare:workers" and drizzle(env.DB, { schema }). No threading env through every request, no wrapper to smuggle it into module scope, no container. Any research or code that proposes such a workaround is working from stale docs; verify against the current Cloudflare docs for the cloudflare:workers module instead.
- Tests are not optional. Every building ticket lands with its own tests. Local unit and integration runs stay under ten seconds; full CI including Playwright stays under five minutes.
Decisions so far
Decided while charting, before any ticket:
- All Cloudflare, no Docker. Workers, D1, R2, Durable Objects. Self hosting means deploying to your own Cloudflare account.
- better-auth owns identity, every method it supports. Organisations are workspaces, members are memberships. ADR 0001 (email code only) was deleted. Polar plugs into it for hosted plans.
- Self hosted instance = the same app with billing off. Instance mode is one boot time question: is Polar configured.
- Per workspace storage preference: a Durable Object per workspace with its SQLite storage, falling back to one shared D1 with workspace_id if the prototype fails.
packages/db dissolves into apps/server/src/db. Its markdown moves with it.
apps/server is its own Worker at api.prismark.tech. Web and mobile are both clients of the same contract.
apps/landing is Astro, custom design, waitlist only for now. Waitlist signups earn an early bird discount; the list lives in the server so sign up can honour it.
- Realtime is Durable Objects with WebSockets, past this map's scope but the storage decision must not preclude it.
Charting also said "Effect v4 is the server's shape". The transport ticket replaced that; see below.
Research resolved:
- Effect v4 on Cloudflare Workers: runs on workerd through web-standard handlers, no Workers package needed;
effect@rc is 4.0.0-rc.112, not GA. Its own HttpApi gives client and OpenAPI from one declaration. Two open bugs matter: Durable Object SQLite transactions through @effect/sql-sqlite-do, and a cold-start wedge on aborted first requests. Findings.
- oRPC v2 in front of an Effect server: a real beta (2.0.0-beta.32, docs at orpc.dev). Contract first with no codegen, Effect Schema through Standard Schema, one TanStack Query package for React and React Native, OpenAPI 3.1. Effect glue is
@orpc/experimental-effect, about 1 kB. Tagged errors need a hand-written map to ORPCError. Recommends oRPC plus packages/contract, with Effect's HttpApi as the fallback. Findings.
- better-auth on Workers and D1: v1.7.2 runs on Workers as a module-scope constant on
drizzle(env.DB). Organisation plugin gives organization, member, invitation: workspace and membership map directly; project roles and the client's company stay on our tables. Passwordless methods are plugins that need an email sender. Cookie shared across subdomains via crossSubDomainCookies. Expo client stores the cookie in SecureStore and takes a runtime base URL. Polar plugin adds no tables and is simply left out when unconfigured. Findings.
- Workspace storage: Durable Object per workspace is viable with
drizzle-orm/durable-sqlite, 10 GB per object, idFromName(workspaceId) routing, WebSocket hibernation on the same object, real objects in tests. D1 per tenant fails on binding, not storage: bindings are static at deploy time. Recommends the object; shared D1 stays the fallback. Failure conditions named. Findings.
- Testing on Workers: the pool is now
@cloudflare/vitest-plugin on Vitest 4.1, which supports Vite 8. Storage isolation is per test file; D1 migrations apply from cloudflare:test; Durable Objects run for real. Vitest projects let a workerd project and a Node project share one run. Playwright boots both servers from webServer. Expo unit tests are Jest, not Vitest. Turborepo --affected scopes CI. Findings.
- Deploying three Workers: one Workers Builds connection per app with its own root directory; pin
PNPM_VERSION. Custom domains as routes; cookie sharing is Domain=prismark.tech. Astro static needs no adapter. The Deploy to Cloudflare button deploys one Worker from one wrangler config and needs a fully isolated subdirectory, which workspace:* packages break. Durable Object lifecycle now lives in the exports map. Plain HTTPS from web to server for the skeleton. Findings.
- Polar for hosted plans: one Polar organisation sells the catalogue; a workspace is a Polar customer by external id. Discounts cannot be locked to an email, so the server mints a code per waitlist signup and passes
discountId at checkout. Webhooks are Standard Webhooks, verifiable with pure JS. Sandbox is a separate environment and still hits the network. Proposes Plan as the glossary word. Findings.
Decisions resolved:
- Transport: Effect's HTTP layer alone, or oRPC in front: oRPC v2 only; Effect is dropped. Zod 4 schemas,
packages/contract as @prismark/contract shared by all three apps, error classes declared in the contract and thrown by services so types survive to the React component with no map, plain module singletons for dependencies, isomorphic TanStack Start loaders, /auth /rpc /openapi.json /reference on the API Worker. The folder tree for the skeleton is in the resolution comment. Effect stays adoptable per handler if a feature ever needs it.
Not yet specified
- Realtime: what the workspace object pushes, and what the clients subscribe to.
- The glossary word for a hosted plan (research proposes Plan) and the Polar subscription lifecycle past the early bird discount.
- Docs at prismark.tech/docs, including the self host guide. Reserved inside
apps/landing, not written.
- Email delivery for better-auth's magic links and codes (its passwordless plugins take a send callback and ship no sender), and how a self hoster supplies their own provider.
- Authorisation on top of better-auth roles: project roles and client members through their company.
- File uploads through the server to R2.
- Phone end to end tests with Maestro, once the phone app has more than sign in. Phone unit tests are Jest, not Vitest, so they need their own Turborepo task.
- Background work on cron triggers: purging attempts, cleaning deleted files.
- Per workspace data export.
- The self host button and
workspace:* packages: the button needs a fully isolated subdirectory, so the server may need a publish step or a bundled deploy. Sharpened by the deploy research; owned by the deploy ticket until it proves too big.
- Typed errors in the server's interior: a Result library between services once one of them has more than one failure kind. The transport ticket left it out of the skeleton on purpose.
- REST shaped routes through oRPC's
OpenAPIHandler for third party integrations. The spec exists from day one; the handler waits for a consumer.
Out of scope
- Docker or any non Cloudflare self host. Ruled out while charting; one codebase, one platform.
- Every feature past sign in and the waitlist: projects, tasks, chat, money, documents, portal.
- Build time variants of the app. This repo ships one product.
- Effect as the server's shape. Chosen while charting, dropped by the transport ticket after research; it returns only per handler, as a feature's own choice, never as the map's.
Destination
A walking skeleton on the chosen stack.
apps/serverruns sign up, sign in, and invite through better-auth, plus one waitlist route.apps/webat app.prismark.tech andapps/mobile(against any instance) consume it through the chosen contract.apps/landingis live at prismark.tech with the waitlist page. Unit, integration, and web end to end tests are green in CI, and every app deploys through Cloudflare with a Deploy to Cloudflare button in the README. Nothing past sign in and the waitlist is built.Notes
docs/adr/only once the skeleton proves it, by the ticket that proved it.CONTEXT.mdis the vocabulary; use it in every title, test name, and comment. Sign up, Invite, and Sign in were redefined while charting; Waitlist entry was added by the transport ticket. ADR 0007 (Cloudflare for everything) and ADR 0008 (latest everything) stand and bind every ticket.middleapi/orpcsource. Same for Zod 4, better-auth, Drizzle, and Cloudflare's Vitest plugin. Effect is not in the stack; anything proposing it is working from the old map.grillinganddomain-modeling. Building tickets calltddandcodebase-design. Research tickets callresearch.cloudflare:workers. Current Workers code readsenvat module scope:import { env } from "cloudflare:workers"anddrizzle(env.DB, { schema }). No threadingenvthrough every request, no wrapper to smuggle it into module scope, no container. Any research or code that proposes such a workaround is working from stale docs; verify against the current Cloudflare docs for thecloudflare:workersmodule instead.Decisions so far
Decided while charting, before any ticket:
packages/dbdissolves intoapps/server/src/db. Its markdown moves with it.apps/serveris its own Worker at api.prismark.tech. Web and mobile are both clients of the same contract.apps/landingis Astro, custom design, waitlist only for now. Waitlist signups earn an early bird discount; the list lives in the server so sign up can honour it.Charting also said "Effect v4 is the server's shape". The transport ticket replaced that; see below.
Research resolved:
effect@rcis 4.0.0-rc.112, not GA. Its own HttpApi gives client and OpenAPI from one declaration. Two open bugs matter: Durable Object SQLite transactions through@effect/sql-sqlite-do, and a cold-start wedge on aborted first requests. Findings.@orpc/experimental-effect, about 1 kB. Tagged errors need a hand-written map toORPCError. Recommends oRPC pluspackages/contract, with Effect's HttpApi as the fallback. Findings.drizzle(env.DB). Organisation plugin gives organization, member, invitation: workspace and membership map directly; project roles and the client's company stay on our tables. Passwordless methods are plugins that need an email sender. Cookie shared across subdomains viacrossSubDomainCookies. Expo client stores the cookie in SecureStore and takes a runtime base URL. Polar plugin adds no tables and is simply left out when unconfigured. Findings.drizzle-orm/durable-sqlite, 10 GB per object,idFromName(workspaceId)routing, WebSocket hibernation on the same object, real objects in tests. D1 per tenant fails on binding, not storage: bindings are static at deploy time. Recommends the object; shared D1 stays the fallback. Failure conditions named. Findings.@cloudflare/vitest-pluginon Vitest 4.1, which supports Vite 8. Storage isolation is per test file; D1 migrations apply fromcloudflare:test; Durable Objects run for real. Vitest projects let a workerd project and a Node project share one run. Playwright boots both servers fromwebServer. Expo unit tests are Jest, not Vitest. Turborepo--affectedscopes CI. Findings.PNPM_VERSION. Custom domains as routes; cookie sharing isDomain=prismark.tech. Astro static needs no adapter. The Deploy to Cloudflare button deploys one Worker from one wrangler config and needs a fully isolated subdirectory, whichworkspace:*packages break. Durable Object lifecycle now lives in the exports map. Plain HTTPS from web to server for the skeleton. Findings.discountIdat checkout. Webhooks are Standard Webhooks, verifiable with pure JS. Sandbox is a separate environment and still hits the network. Proposes Plan as the glossary word. Findings.Decisions resolved:
packages/contractas@prismark/contractshared by all three apps, error classes declared in the contract and thrown by services so types survive to the React component with no map, plain module singletons for dependencies, isomorphic TanStack Start loaders,/auth/rpc/openapi.json/referenceon the API Worker. The folder tree for the skeleton is in the resolution comment. Effect stays adoptable per handler if a feature ever needs it.Not yet specified
apps/landing, not written.workspace:*packages: the button needs a fully isolated subdirectory, so the server may need a publish step or a bundled deploy. Sharpened by the deploy research; owned by the deploy ticket until it proves too big.OpenAPIHandlerfor third party integrations. The spec exists from day one; the handler waits for a consumer.Out of scope