THE BOOK

BuildFast

How to ship better web and Apple apps when you know nothing about code

For builders who ship with AI agents and want to understand everything behind the curtain of the stack they already use.

Edition 2026-07 · every price in this book carries its as-of date

CHAPTER 01 · THE MAP

What happens when you visit a website

Why can you type slatereader.com, press Return, and see Slate before you have time to think about everything that just happened?

Call it the 300-millisecond journey. That is not a promise about every visit. It is the right order of magnitude for one real trip: on July 16, 2026, a direct request from the BuildFast workspace reached the first byte from slatereader.com in 241 milliseconds and returned 200 (recorded live probe).

Those 241 milliseconds did not belong to one machine, but they did not measure the whole page either. Time to first byte covers the request only through the first response byte: name resolution, connection setup, routing, and whatever response work that anonymous route performed. Database work and browser rendering belong to later authenticated and browser stages; the anonymous request need not touch Supabase at all.

The useful trick is to stop seeing “the website” as one object. A visit is a relay race. Each runner has one bounded job, and each can fail while all the others remain healthy.

How a Slate request travels from the browser to a rendered page RECORDED ROOT REQUEST · JULY 16 browser cachecheck recursive DNSlookup Cloudflareauthoritative DNS TLSconnection VercelCDN 200 rootresponse browser render reverse proxyonly when DNSrecord is proxied DRIFT · July 15 observed 307 /login;July 16 recorded 200. AUTHENTICATED ROUTE + RENDER PIPELINE Vercel CDN /cache check Vercel Function pooledconnection SupabasePostgres HTMLresponse linked CSS + JavaScript requests→ DOM / CSSOM → layout → paint Cloudflare authoritative DNS configures the destination; it does not carry this HTTP request.
How a Slate request travels from the browser to a rendered page

Before the network: the browser checks what it already has

Your browser does not begin every visit by asking the internet for every file again. It keeps responses in an HTTP cache. A response's headers tell the browser whether it may reuse that response, whether it must check that the response is still current, or whether it must not store it. A stale cached response can often be validated with a small conditional request instead of downloading the whole body again. That behavior comes from HTTP caching rules, not from React or Next.js (MDN, “HTTP caching”).

The distinction matters because “loading Slate” is not one request. The initial HTML can refer to stylesheets, JavaScript bundles, fonts, and images. Each resource gets its own cache decision. Your browser may already have a versioned JavaScript bundle while still asking for fresh HTML. On the recorded July 16 root response, Slate sent Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate (recorded live probe). private excludes shared caches and no-store says not to store the response, so the browser must refetch it rather than reuse a saved copy.

When this step breaks, the failure often looks stale rather than dead. You deploy a fix, yet one browser shows an older interface. Or the cache is bypassed and every visit downloads more than it should. The server can be perfectly healthy in both cases. The bug is in the agreement about reuse.

DNS: turning a name into a destination

slatereader.com is for humans. A connection needs a network address. DNS is the distributed directory that connects those two forms.

Your browser and operating system first check their own recent DNS answers. If they do not have a usable answer, they ask a recursive resolver, usually supplied by the network or chosen in device settings. That resolver walks or reuses the DNS hierarchy until it reaches the authoritative nameservers for the domain. “Authoritative” means those servers publish the domain owner's current answer; it does not mean they necessarily serve the website.

Cloudflare is authoritative DNS for all 31 active zones in this portfolio, including slatereader.com, healthymandaily.com, and gethestya.com. All 31 were on Cloudflare's Free plan when the live account was pulled on July 15, 2026 (live account inventory). A public lookup for Slate named cecelia.ns.cloudflare.com and kenneth.ns.cloudflare.com as the authority. The application itself is one of the 23 projects in the owner's Vercel team (live account inventory).

This is your first clean separation of jobs. Cloudflare answers, “Where should traffic for this name go?” Vercel answers, “Which deployment and route should handle this request?” Moving DNS to Cloudflare did not move Slate's code there.

When DNS breaks, the browser cannot start the secure web connection. A missing record can produce a “domain not found” error. A stale record can send you to an old host. A wrong record can send only one hostname—perhaps www but not the apex—to the wrong place. Vercel and Supabase can both be healthy while Slate appears gone because the directory points elsewhere.

TLS: proving the site and making a private tunnel

The https:// in the address bar changes the next step. Before the browser sends a normal HTTP request, it negotiates TLS with the endpoint. The endpoint presents a certificate for the hostname. The browser checks that a trusted certificate authority signed it, that it covers the requested name, and that it is valid. The two sides agree on cryptographic keys for the connection. After that handshake, intermediaries on the network can see that traffic exists, but they should not be able to read or alter the protected HTTP contents. TLS supplies encryption, integrity, and server authentication for HTTPS (MDN, “Transport Layer Security”).

This is why a certificate problem feels different from an application bug. An expired certificate, a hostname mismatch, or an untrusted chain can stop the browser before Slate code runs. You do not fix that in a React component. You fix the certificate or the domain-to-platform configuration.

A successful handshake also creates reusable connection state. Modern HTTP can carry several resource requests over the same connection instead of paying for a new handshake every time. That helps the HTML, CSS, JavaScript, and image requests act like one page even though they are separate exchanges.

Cloudflare proxy: a separate switch from Cloudflare DNS

Cloudflare can do more than answer DNS. If an A, AAAA, or CNAME record is marked Proxied, DNS returns Cloudflare addresses and the HTTP request enters Cloudflare's network first. Cloudflare can then apply caching, security rules, redirects, and traffic protection before forwarding a request to the origin. If the record is DNS only, Cloudflare still answers the DNS question, but the browser connects directly to the origin (Cloudflare proxy-status documentation).

That switch is easy to miss because both modes live in the same Cloudflare dashboard. It is also the difference between “Cloudflare manages the domain” and “this page request passed through Cloudflare.”

For a Vercel origin, DNS only is the deliberate default in this stack. Putting Cloudflare's proxy in front creates a second CDN and security boundary that can add latency and obscure signals Vercel uses for bot protection; use that double-proxy path only for a measured reason, not because the orange cloud looks more secure (Guillermo Rauch on the DNS-only default, follow-up on bot protection).

Here is a live example of why you check instead of assuming. The portfolio's own notes said Cloudflare was "proxying to Vercel." The claim was tested against the Cloudflare API on July 15. Result: the apex A record (76.76.21.21) and the www CNAME (cname.vercel-dns.com) are both DNS only (proxied: false), and the same is true for healthymandaily.com and gethestya.com. The recorded July 16 request reported remote_ip: 76.76.21.21 and server: Vercel, matching that DNS-only A record and strengthening the conclusion that Cloudflare answers the DNS question while the HTTP request goes straight to Vercel (recorded live probe). The notes were wrong, and the API plus probe settled it.

If the record is proxied, Cloudflare is another TLS and HTTP boundary in front of Vercel. If it is DNS only, draw no imaginary hop: the request goes from the browser to Vercel after Cloudflare answers DNS.

When the proxy layer breaks, you can see a Cloudflare-branded error even though Vercel works at its direct deployment address. A rule can redirect a path unexpectedly. A cache can retain the wrong response. A security rule can reject a real visitor. The proxy is valuable precisely because it can act before the app, but that also gives it the power to block the app.

Vercel's edge: choosing a deployment and deciding whether code must run

The request next reaches Vercel's globally distributed CDN. Vercel says every deployment request passes through this CDN, where routing, caching, security, and compression can be resolved before application code runs. A cached response can be returned at the network edge. A cache miss or dynamic route can continue to a Vercel Function (Vercel, “How Vercel CDN works”).

On July 15, the anonymous root was seen returning 307 /login; the recorded July 16 probe returned 200 instead. Deployments change behavior between days, which is why measurements carry dates and recorded artifacts (recorded live probe). The earlier redirect remains a useful teaching device: a request does not need to reach the database just because Slate uses one. Vercel's routing layer or Slate's server route can send a tiny redirect without querying application data.

Whether Vercel returns the root response or routes a follow-up after a redirect, it chooses the production deployment attached to slatereader.com. This mapping is separate from DNS. DNS gets the request to Vercel; Vercel's domain configuration gets it to the slate project and the correct immutable deployment. The live team has 23 projects, including Slate, HMD, and Hestya, so this routing decision prevents one shared platform account from becoming one undifferentiated website (live account inventory).

When this layer breaks, a deployment can build but never receive the custom domain. A bad redirect can loop. A cached error can outlive the function that caused it. Or a function can fail while static assets still load, leaving you with a styled shell and no data. “Vercel is up” is not a diagnosis; you need to know which layer answered.

The server function: trusted code for this request

For a dynamic authenticated route, Vercel invokes server-side code produced from the Next.js application. Server-side rendering on Vercel runs through Vercel Functions, and the function can check authentication, call other services, query a database, and generate a response that is unique to the request (Vercel's Next.js documentation).

This is the part of Slate that can safely hold server-only configuration. The browser does not receive the database password. Instead, it sends session evidence with the request. Server code decides which operation is being attempted and talks to the database over a server-side connection.

Slate makes that boundary concrete. Its browser-facing Supabase client is used for Auth with a public URL and anon key. Application data is queried by Next.js server code through a separate pooled SLATE_DB_URL, and each query names Slate's schema explicitly (shared-project recipe). An agent may generate both files in one coding session, but production treats them as different programs with different authority.

When the function breaks, Vercel can still serve static CSS and JavaScript. The page may look half-alive. A missing environment variable can make database connections fail. A thrown exception can turn one route into a 500 response while the marketing page stays healthy. A slow downstream call can make the function wait until the user gives up.

Supabase: finding the allowed rows

Slate's function opens or borrows a Postgres connection and sends a query. The database parses the query, finds the relevant tables and indexes, checks permissions and row-level policies, reads or changes rows, and returns a result. Supabase supplies the managed Postgres database plus Auth and other services around it; it is still a full Postgres database rather than a proprietary look-alike (Supabase database overview).

For Slate, the important question is not merely “does an item with this ID exist?” It is “does this item belong to the signed-in user?” Slate stores the shared Auth identity in user_id on user-owned rows and enables row-level security policies that compare it with the current authenticated identity (shared-project recipe). Chapter 7 pulls that boundary apart.

When the database breaks, the HTML shell may still return while a reading queue is empty or errors. Too many connections can prevent new work from starting. A slow query can hold the server function open. A bad migration can affect every route that expects the old shape. In Slate's shared Supabase project, the blast radius can cross app schemas because compute, Auth, quotas, and configuration remain project-wide (2026 stack audit).

Back to the browser: bytes become a page

The return trip carries an HTTP response: status, headers, and a body. For a rendered route, the body can contain HTML that already describes meaningful page structure. The browser parses that HTML into the Document Object Model. It fetches referenced CSS and builds a CSS object model. It combines the relevant structure and styles into a render tree, calculates geometry during layout, and paints pixels. JavaScript can then add behavior and change the DOM, sometimes triggering more layout and paint work (MDN, “Critical rendering path”).

This is why “the server responded” and “the page feels ready” are different milestones. Time to first byte ends when the first response byte arrives. The browser may still need the rest of the HTML, blocking styles, fonts, JavaScript, and later API data. A giant JavaScript bundle can make a fast server feel slow. Broken CSS can make correct HTML unusable. A client error can stop buttons after the page has painted.

The 300-millisecond journey is therefore not one stopwatch and not always one route. It is a chain of cache decisions, name resolution, secure connection setup, platform routing, optional server and database work, response transfer, and browser rendering. Once you can name the current runner, “the site is down” becomes a much smaller question.

On your stack
  • Cloudflare is authoritative DNS for all 31 active portfolio zones; that fact alone does not prove an HTTP request is proxied (live inventory).
  • Slate, HMD, and Hestya are custom-domain projects inside the 23-project Vercel team, so Vercel maps each hostname to its own deployment (live inventory).
  • The recorded July 16 anonymous Slate root returned Vercel 200; the prior day's observed 307 /login shows why measurements need dates, while authenticated routes can continue through server code to the pooled Supabase connection (recorded live probe).
  • Slate keeps Auth in the public Supabase client but keeps application-data queries and SLATE_DB_URL on the server (shared-project recipe).
  • When a page fails, identify the last completed hop—cache, DNS, TLS, proxy, Vercel routing, function, database, or render—before changing code.

CHAPTER 02 · THE MAP

The builder's map: where code actually runs

When an agent says it “built the save feature,” where did that code actually go?

Not into one place called Slate.

Slate is one product in your head and several cooperating programs in production. Some code runs in the reader's browser. Some runs only after Vercel receives a request. Some executes inside Postgres as constraints and row policies. Some runs at the network edge before a server function is chosen. They may live in one repository and share TypeScript types, but they do not share a machine, a trust level, or a lifetime.

That is why a modern app can feel like four programs in a trench coat. The coat is the interface. Underneath it, each program wakes for a different reason, has access to different data, and can fail without taking all the others down.

The cleanest map is not a list of vendors. It is a list of execution boundaries: frontend, backend, database, and edge. Trace one real Slate action—saving an article—and those labels stop being architecture vocabulary. They become four answers to one practical question: “who does the next piece of work?”

Save one article in Slate across browser, Vercel, and Supabase SAVE ONE ARTICLE IN SLATE BROWSER / FRONT ENDVERCEL EDGE + ROUTINGVERCEL FUNCTION / BACK ENDSUPABASE POSTGRES Cloudflare authoritative DNSDNS only — not an HTTP proxy hop signed-in readersubmits article URL URL + session evidence select Slate deployment+ dynamic route resolve authenticated user SSRF-GUARDED FETCHvalidate + pin every publicdestination and redirectextract + sanitize returned HTML schema-qualified INSERT slate.items constraints + grants alwaysRLS if live role is subjectotherwise server code isthe authorization layer response returns through Vercel → browser updates
Save one article in Slate across browser, Vercel, and Supabase

Frontend: the program in somebody else's browser

The frontend is the code and markup that become the visible interface. The browser receives HTML, CSS, JavaScript, images, and fonts; it parses, lays out, and paints them, then runs client-side JavaScript for interaction. The browser's rendering path and JavaScript runtime are separate from the server that sent those files (MDN, “Critical rendering path”).

For Slate, the frontend owns the part of saving an article that the reader can touch: collect the URL, submit the request, show progress, and reflect the result. It can reject an obviously empty field for better feedback. It can disable a button while a request is pending. It cannot be the final authority on whether the signed-in person may write a row.

That limitation is structural. Browser code runs on a device you do not control. A person can inspect the downloaded JavaScript, change it locally, replay its requests, or skip the interface and call an endpoint directly. Anything sent to the browser should therefore be treated as public, including configuration explicitly intended for browser use. Supabase distinguishes its public client configuration from server-side secrets, and warns that the service-role key must never be exposed on the frontend because it bypasses Row Level Security (Supabase API-key guidance).

Slate follows that boundary. Its browser-facing Supabase client uses the public project URL and anon key for Auth. Application data goes through Next.js server code using the separate, server-only SLATE_DB_URL (shared-project recipe). The frontend may initiate “save this,” but it does not receive a privileged database connection that means “save anything as anyone.”

When this layer fails, the page can look wrong while the backend remains healthy. A JavaScript error can leave a button inert. A stale bundle can show an old interaction. A response can succeed but never be rendered. The diagnostic question is not “did Slate save?” but first “did the browser send the intended request, and what did it do with the response?”

The edge: the program that meets the request first

“Edge” is a location and a job, not one universal product. Edge code or routing runs in a provider's distributed network close to incoming traffic. It can terminate TLS, match a hostname and path, serve cached files, redirect a request, enforce a rule, or choose which regional function should continue. Vercel says every deployment request passes through its CDN, where routing, caching, security, and compression can be handled before a function runs (Vercel CDN documentation).

On the Slate save path, Vercel's network receives the HTTPS request, maps slatereader.com to the Slate production deployment, and routes the dynamic request onward. Static assets can be answered from cache; a request that needs user-specific server work continues to a Vercel Function. That early routing is edge work even when no custom “edge function” exists.

Cloudflare is also an edge platform, but draw only the path that is real. Cloudflare is authoritative DNS for slatereader.com: it answers where the name points. The apex and www records were both proxied: false when checked through the Cloudflare API on July 15, 2026, so the HTTP save request goes straight to Vercel after DNS resolution. The same DNS-only result applies to HMD and Hestya (live account inventory). Cloudflare's CDN and WAF are not imaginary extra hops in those hero-app requests.

The portfolio does have real custom code on Cloudflare's edge. slate-email-ingest is the account's single Worker, triggered by Email Routing rather than by Slate's normal web page. It accepts bounded inbound mail, signs a bounded request, and forwards it to Slate's backend (email-ingress recipe). That is another program in Slate's coat, but it is not part of saving a web URL.

When the edge fails, the browser may never reach the backend. A route can point at the wrong deployment, a redirect can loop, or a cache can retain the wrong response. Conversely, the edge can serve Slate's shell while its dynamic function is broken. “The homepage loads” does not prove “save article works.”

Backend: the trusted coordinator

The backend is code that runs under your control after a request, job, or event reaches a trusted runtime. For Slate's Next.js routes, that runtime is a Vercel Function. Vercel's Next.js integration runs server-side rendering and server endpoints through its managed function platform (Vercel's Next.js documentation).

The save request arrives with session evidence and an untrusted article URL. The backend resolves the signed-in identity, applies product rules, and coordinates work that the browser must not perform with privileged credentials. It also becomes the network client that fetches the submitted URL.

That last sentence is where “backend” becomes a security boundary. A plain server-side fetch(url) would let user input decide where your production machine connects. Slate instead funnels arbitrary article and feed destinations through one guarded primitive. It accepts only HTTP or HTTPS, rejects credentials and local destinations, classifies IPv4 and IPv6, requires every DNS answer to be public, pins the socket to a validated address, manually revalidates up to five redirects, and bounds both time and streamed bytes (SSRF-guarded extraction recipe).

Only after that boundary returns content does Slate perform media detection or Readability extraction. It restricts canonical URLs, sanitizes extracted HTML, and preserves an honest fallback item when ordinary extraction fails. An unsafe destination remains a validation failure rather than silently falling back to raw fetch (SSRF-guarded extraction recipe).

The backend then prepares the database change. Its query explicitly names Slate's schema and carries the already-resolved user identity. This is coordination, not omnipotence: the function should ask for one allowed operation, and the database should still enforce the durable rules.

When this layer fails, the interface and static files may continue to work. A missing server environment variable can break only dynamic routes. An outbound fetch can time out. Extraction can return a partial result. A database connection can be unavailable. The function's status and logs tell you whether the request died before fetching, during extraction, or while persisting.

Database: the program made of data rules

The database is not a passive hard drive. Postgres executes queries and enforces types, required fields, unique constraints, foreign keys, transactions, privileges, and row policies. Row Level Security can decide which rows a role may read or change inside the database, even if application code asks too broadly (PostgreSQL row-security documentation).

Slate owns a dedicated slate schema inside a shared Supabase project. Its items rows carry user_id, reference the shared auth.users identity, and enforce per-user URL uniqueness. Queries use schema-qualified names instead of relying on a mutable search path (shared-project recipe).

For client-accessible user data, Slate's RLS policies compare auth.uid() with user_id. An update checks both the old row through using and the proposed row through with check, so an allowed owner cannot rewrite ownership as part of the change. Server-only ledgers take the opposite shape: RLS is enabled without ordinary client policies, producing default denial (shared-project recipe).

One nuance matters in this exact save path. Slate's application queries use a pooled server-only SLATE_DB_URL, and the adopted recipe does not record whether that live connection role is subject to RLS or bypasses it. ABSENT — resolve by: inspect the role encoded by SLATE_DB_URL, list its ownership, grants, and BYPASSRLS behavior, then execute owner-allowed, cross-user-denied, and service-only-denied tests through the same production-shaped access paths.

That unknown does not erase the architecture. It identifies the trusted boundary precisely. If the role is privileged, the backend's user check is part of authorization and RLS remains the last wall for less-privileged paths. If the role is subject to RLS, the query must carry the authenticated database identity needed by the policy. Either way, the test—not the presence of a policy file—settles the claim.

When the database layer fails, you may see a duplicate rejected by a uniqueness constraint, an ownership policy deny a row, a pool run out of connections, or a migration leave code and schema out of step. Retrying the frontend does not fix those causes. You inspect the query, role, constraint, policy, and transaction.

What “serverless” actually removes

Vercel calls the backend a function because you deploy code without provisioning a long-lived application server. A matching request causes the platform to schedule an instance, run the handler, and meter its work. The platform manages the machines, runtime placement, scaling mechanics, TLS, and much of the delivery infrastructure; your code still executes on servers owned and operated by somebody (Vercel Functions documentation).

So serverless means server management is abstracted, not servers disappeared. You still own the handler's correctness, timeouts, memory use, external calls, database connections, idempotency, logs, and bill. The runtime can start multiple instances during a burst, which is why a tiny per-instance database pool does not become one global connection ceiling. Supabase recommends pooler modes suited to temporary serverless clients because connection behavior changes when compute is short-lived (Supabase connection guide).

Serverless also changes state. You cannot assume the next request lands on the same instance or that an in-memory variable survives. Durable truth belongs in Postgres or another designed store. A cache inside one function instance can be an optimization; it is not Slate's entitlement ledger or reading queue.

The useful sentence is: “This save request may run in a fresh backend instance, but it reaches the same durable database rules.” That is the bargain.

One repository does not mean one authority

An agent can edit a React component, a route handler, a SQL migration, and a Worker in one session. Git can commit them together. TypeScript can share types between them. Deployment breaks that apparent unity apart.

The browser program is public and adversarial. The Vercel function holds server authority and coordinates the save. Postgres preserves data and enforces durable constraints. Edge systems route events and requests before the backend. Each boundary should receive only the secrets, network access, and permissions its job requires.

Once you see the map, debugging becomes a trace. Did the browser create the request? Did Vercel route it to the production function? Did the guarded fetch admit the destination and return bounded content? Did extraction produce a valid item? Did the database accept the row for the resolved user? Did the response reach the interface?

“Slate saved an article” is the product sentence. The system sentence is four programs completing a contract in order.

On your stack
  • Slate, HMD, and Hestya are custom-domain projects in the 23-project Vercel team; Vercel supplies their real edge routing and function runtime (live inventory).
  • Cloudflare is authoritative DNS for all 31 portfolio zones, but Slate, HMD, and Hestya are DNS-only—not proxied through Cloudflare's HTTP edge (live inventory).
  • Slate's real web-save backend sends arbitrary destinations through the guarded fetcher before extraction or storage (SSRF recipe).
  • The browser uses public Supabase Auth configuration; schema-qualified application queries use server-only SLATE_DB_URL into the shared project's slate schema (shared-project recipe).
  • Verify the live database role and negative authorization paths before calling RLS the final wall for every Slate query (shared-project recipe).

CHAPTER 03 · THE MAP

Your machine: what the terminal, git, and cmux are doing

When an agent says it changed a file, ran a command, committed, and pushed, what actually moved—and where does the work exist now?

Four different things are hiding inside that sentence. A file is stored data. A process is a running program. A shell is a program that reads commands and starts other programs. Git is a database of recorded versions. cmux is the window in which you supervise several shells and coding agents.

They can all appear as lines of text in one terminal pane, which makes them feel like one machine. They are not. An agent can change a file without committing it. It can commit without pushing. It can push without deploying. Closing a pane can kill a running process without deleting the files that process already wrote. Once you separate those boundaries, the terminal stops looking like a spell book.

Zach's Mac separates files, shells, child processes, cmux, and remote services ZACH'S MAC · CMUX WORKSPACE · MULTIPLE INDEPENDENT STACKS PANE A CHILD PROCESSESeditor / agenttestslocal devserver shell processcwd → Slate FILESYSTEMSlate workingdirectoryhidden .git PANE Bchild processesshell + cwdfilesystem + .git PANE Cchild processesshell + cwdfilesystem + .git iCloud syncscurrent files ↔not commit graph or release authority GitHub remotereceives commitsgit push Vercel deploymentdeploymentautomation,not Git
Zach's Mac separates files, shells, child processes, cmux, and remote services

A file is bytes with a name and a place

Slate's code is a tree of folders and files. The path tells the operating system where each file belongs. A TypeScript source file, a migration, an image, and a Markdown note are all stored bytes; the program that opens them gives those bytes meaning. The filesystem also stores metadata such as permissions and modification time.

The terminal normally shows one current working directory. That is the folder relative paths begin from. If the prompt is inside Slate, app/api/ingest/route.ts names Slate's ingest route. If the prompt is one folder higher, the same text names a different place or nothing at all. pwd prints the current directory; ls lists entries; neither changes the application.

This is why agents announce a working directory and why repository instructions matter. A command is interpreted in a place. Run the right command in HMD instead of Slate and it can be perfectly valid while changing the wrong product. Git also discovers a repository by looking for its administrative data, normally the .git directory, from the current directory upward (Git repository layout).

A file being present does not mean Git knows about it. Git classifies working-tree files as tracked or untracked. A tracked file can be unchanged, modified, or staged; an untracked file is outside the next commit until it is deliberately added (Git, “Recording Changes”). Generated build output and secrets are commonly excluded through ignore rules, so “it exists on the Mac” and “it belongs in the product's history” are separate claims (Git ignore documentation).

A process is a program while it is alive

A source file is inert. A process is what exists when macOS loads a program and runs it. The shell in a pane is one process. When it starts git status, Git is another. When an agent runs the test suite, the test runner and its helpers are child processes. When you start a Next.js development server, that server remains alive and listens for requests until it exits or is stopped.

Processes have their own identities, environment, open files, and exit status. The shell uses a command's exit status to distinguish success from failure; by convention, zero means success and nonzero means failure (Bash manual, exit status). That small number is how an agent can say a test command passed by execution rather than by reading the test file.

Closing a terminal pane can end the shell and processes attached to it. It does not rewind filesystem writes already completed. A development server disappearing when cmux closes is a process event. A saved source file remaining afterward is a filesystem event. A half-written or unsaved editor buffer is a third case: it may never have reached the file at all (cmux getting started).

Environment variables also belong to this running-world boundary. A shell can pass names and values to a child process. That is how local programs receive configuration without placing every value in source code. The child does not automatically rewrite those values into Git, and Git does not protect a secret that an agent explicitly writes into a tracked file. The boundary is procedural as well as technical.

The shell is the command interpreter

The terminal is the visible surface. The shell is the program reading the command line inside it. cmux can show a pane; a shell such as zsh runs in that pane; the shell finds and launches programs such as git, rg, npm, or a coding agent.

Shell punctuation has behavior. A pipe sends one program's output to another. Redirection can replace or append to a file. Quoting changes which characters are treated literally. A command pasted into a shell is executable input, not prose. That is why reading a destructive-looking command before Return matters even when an agent wrote it.

The shell also expands relative paths and variables before a program sees them. The practical reading order is: which directory is this shell in, what program will it start, what arguments and redirections will the shell construct, and what files or network services can that program reach? You do not need to memorize the shell grammar to ask those four questions.

Commands teach different kinds of truth. git diff reports an uncommitted content difference. A test command reports behavior under its test environment. A local server reports that a process can serve the current working tree. None alone proves that the same commit is on GitHub or running at slatereader.com.

Git records snapshots, not a synced folder

Git is version control: it records changes so you can retrieve, compare, and combine specific versions later (Git, “About Version Control”). In day-to-day use, you see three local states.

The working tree is the files you and the agent are editing. The staging area, also called the index, is the exact selection prepared for the next record. The repository stores commits and the objects they reference. git add copies the chosen version of a file into the staging area; editing the file again afterward does not silently update that staged copy. git commit records the staged snapshot with its parent and message (Git, “Recording Changes”).

A commit is therefore not “everything currently in the folder.” It is a durable node in a history graph containing the selected project snapshot, its relationship to earlier history, authorship metadata, and a message. Files left unstaged remain only in the working tree. Ignored or untracked files remain outside the commit.

Branches are movable names pointing into that graph. A branch lets work advance from a known commit without copying the whole folder. Merging connects histories; rebasing rewrites a sequence onto a different base. Those operations can make the working files look similar while producing different graphs, which is why agents should report both what changed and which branch/commit contains it (Git branching basics).

Git is the source of truth for code because a commit is addressable, reviewable, comparable, and reproducible across machines. The BuildFast audit describes GitHub as the source and delivery-control layer, not the production runtime (stack audit). That distinction keeps the chain legible: the local working tree is current work; a commit is recorded work; GitHub is shared recorded work; Vercel is a runtime produced from selected recorded work.

Why iCloud sync is not that source of truth

iCloud Drive is valuable for keeping files available and updated across signed-in devices. Apple describes edits made on one device as appearing on the others through iCloud Drive (Apple iCloud Drive guide). That solves availability and synchronization. It does not create Git's staged snapshot, commit parentage, branches, reviewable diffs, or remote references.

Sync also answers a different conflict question. It tries to reconcile current files across devices. Git asks which recorded histories should be combined and makes conflicts part of an explicit source operation. A synchronized deletion or broken file can propagate as efficiently as a good edit. Git preserves committed history independently of what the current working tree looks like.

So “Git, not iCloud, is the source of truth” does not mean the Mac or iCloud folder is unimportant. It means a change is not part of the product's authoritative history until it is intentionally committed, reviewed as needed, and pushed to the chosen remote. iCloud can help keep a working directory available; it should not decide which revision Vercel builds.

What push actually moves

A commit is local. git push contacts a configured remote repository, transfers the Git objects it needs, and asks the remote to update a branch or other reference. The common origin name is only a local nickname for a remote URL. A repository can have more than one remote (Git, “Working with Remotes”).

Push does not upload an arbitrary current folder. It sends reachable Git history. Uncommitted edits, ignored secrets, and an untracked local database do not become part of the push. A remote can reject the reference update when its branch contains work the local branch has not incorporated; the rejection protects history from being silently overwritten (Git push documentation).

Push also does not inherently deploy. GitHub receives repository history. A separate Vercel integration can notice the new commit, run a build, and create a deployment. The audit calls Vercel the system that turns a Git commit into preview and production deployments (stack audit). A successful push can still lead to a failed build, and a successful build can still remain a preview rather than the production domain.

The useful handoff statement is exact: “the files are changed,” “commit contains them,” “the branch is on GitHub,” or “Vercel production is serving that commit.” Each is stronger than the previous one, and none is implied merely by seeing green text in a terminal.

cmux is the control room, not the workers

cmux organizes multiple local shells and coding-agent sessions into macOS workspaces and panes. Its official documentation and open-source repository describe a native macOS terminal built on Ghostty with workspaces, panes, notifications, and agent-oriented command surfaces (cmux getting started, cmux repository). Authoritative documentation was found in the July 2026 audit, so this chapter does not need to infer cmux's basic role from screenshots or memory (stack audit).

The clean mental model is many desks in one room. One pane can hold a Slate agent, another an HMD agent, another tests, and another a server log. cmux helps you see which desk wants attention. It does not merge their working trees, serialize their edits, supply their model subscriptions, or make simultaneous agents safe.

That last point matters most. Two agents changing the same files in the same working tree can overwrite assumptions even if their panes look neatly separated. Separate branches or Git worktrees are a generic industry alternative for creating code boundaries, but this portfolio explicitly does not use them: its main-only policy serializes writable work on main. cmux creates attention and layout boundaries, not code isolation. Its restored layout is not process checkpointing, so reopening a pane does not resurrect a killed agent's unsaved internal state (cmux getting started).

The audit verified public cmux product facts but did not inspect this machine's actual plan, cloud hours, workspace contents, secret exposure, or recovery practice. ABSENT — resolve by: record whether cmux is local Free, Pro cloud, or Team; list active workspaces and their repository/worktree boundaries; confirm idle shutdown and cloud-hour usage; inventory credentials available to each session; and rehearse recovery from Git plus agent logs after a pane is killed. (stack audit)

cmux makes concurrent work visible. Git makes concurrent work reconcilable. Tests make it credible. A push makes it shared. A deployment makes one recorded revision live. Keeping those verbs separate is the whole machine map.

On your stack
  • Treat each app directory as a working tree and each .git database as its recorded local history; use git status and git diff to distinguish files that merely exist from changes selected for a commit (Git recording guide).
  • GitHub is the portfolio's source and delivery-control layer, while Vercel is a separate production runtime built from selected commits (stack audit).
  • iCloud may synchronize the current folder across devices, but the authoritative code revision is the reviewed commit pushed to the chosen Git remote (Apple iCloud Drive guide, Git version-control overview).
  • Use cmux to supervise several shells and agents. Branches and worktrees are a generic industry isolation pattern, not this portfolio's: serialize writable work on main under the main-only policy (cmux getting started).
  • Report shipping state precisely: working-tree change, local commit, pushed branch, successful Vercel build, and production domain are five separate checkpoints (Git remotes, stack audit).

CHAPTER 04 · THE MAP

Agent coding: what the models actually do

When Claude says it delegated a task to Codex, what moved between them—and why can both agents sound certain while the feature is still wrong?

An agent does not hold your whole product in its head. It receives a bounded stream of text, selects more context, predicts a useful next action, calls a tool, reads the result, and repeats. The impressive part is that this loop can inspect a repository, form a plan, edit several files, run tests, and correct failures. The dangerous part is how easily the loop looks like understanding from the outside.

Your real workflow makes that distinction visible. Claude Code can act as the coordinating terminal agent. Codex, using a GPT-5.6-family model, can take a bounded implementation or research lane. Grok Build can take work where fresh X or web material is useful. These are three coding interfaces with different models, tools, billing paths, and strengths—not three engineers who silently share one memory (AI-toolchain audit).

The coordination works when the handoff is explicit. It fails when “take care of this” is mistaken for shared context.

One agent task moves from intent through delegation and verification to evidence ONE AGENT TASK, FROM INTENT TO EVIDENCE Zach → product goal + constraintsClaude Code orchestrates context: routing rubric+ compact repository map CODEX / GPT-5.6 · BOUNDED IMPLEMENTATIONDELEGATION CONTRACTgoalallowed pathsconstraintsnon-goalstest commandstop point GROK BUILD · FRESH X / WEB RESEARCHDELEGATION CONTRACTgoalallowed pathsconstraintsnon-goalstest commandstop point returned artifacts + command output feed verification; “understanding” does not diff review → narrow tests → broader checks → manual production-shaped exercise→ verifier sanity checkverification loop ACCEPTED CHANGE SEPARATE RESOURCE METERScontext tokens ingenerated tokens outtool calls
One agent task moves from intent through delegation and verification to evidence

A model sees tokens, not your repository

Text enters a language model as tokens: pieces of text that may be a word, part of a word, punctuation, or whitespace. The model predicts output tokens from the tokens already in its context. Token counts matter because models and products meter input and output, and because the context window has a finite capacity (OpenAI token explanation, Anthropic context-window guide).

The context window is the model's working packet for one turn or agent loop. It can include your request, system instructions, repository guidance, files the agent opened, search results, command output, tool descriptions, and the model's own recent messages. It is not the model's permanent memory of Slate, HMD, or Hestya. Starting a fresh session without restoring the relevant files and decisions changes what the model can reason from.

More context is not automatically better. A complete stack audit can answer a pricing question, but most of its 842 lines are noise when the task is one RLS policy. Large logs can crowd out the one error line that matters. Two old implementation plans can conflict with the current one. Anthropic's context documentation describes performance considerations as context grows; the practical lesson is to give the model the smallest complete evidence set, not the largest possible dump (Anthropic context-window guide).

That is why repository maps, file paths, adopted recipes, and stop points matter. They tell the agent where to spend its context budget. “Use web-recipes/ssrf-guarded-extraction.md and touch only the save path” is not bureaucratic prompt decoration. It steers the model toward the exact security contract and away from inventing another fetcher.

Context can also become stale within one long session. An agent may retain an early assumption after a command disproves it. A concise checkpoint—accepted facts, changed files, commands run, unresolved failures—helps the next loop work from evidence rather than conversational momentum.

The 2026 practitioner signal is system first, prompt second. Durable project rule files, a compact repository map, reusable skills, permission hooks, and executable checks keep shaping every task after one clever prompt scrolls out of view (project-system workflow, skills and hooks workflow). The prompt still states the job; the system carries the conventions the job must not rediscover.

Use fresh sessions as bounded loops. Finish or checkpoint one contract, open a clean context for the next, and reload only the accepted facts and relevant files. Practitioners report this working more reliably than one mega-chat whose early assumptions and tool output keep accumulating (fresh-session loop, system workflow).

Tool calls turn words into actions

A raw model returns text. A coding agent surrounds it with tools: read a file, search the tree, apply a patch, run a command, inspect Git, query a connected service, or control a browser. Claude Code, Codex CLI, and Grok Build are agentic shells because they can use those capabilities under granted permissions; the deployed app does not depend on the coding CLI after the code is shipped (Claude Code overview, AI-toolchain audit).

A tool call is a structured request. The model chooses a tool and supplies arguments; the host decides whether the call is allowed, executes it, and returns the result. The model then reasons over that result. With Model Context Protocol, a host can discover external tools and resources through a common client-server contract, but every connected server becomes part of the security and reliability boundary (MCP architecture, MCP security policy).

This explains two experiences that otherwise feel mysterious. First, an agent can know a command exists but be unable to run it because the sandbox or approval policy denies it. Second, it can run a command successfully and misunderstand the output. Tool access expands capability; it does not guarantee judgment.

Permissions should match the task. A chapter draft needs read access to the source repo and write access to out/; it does not need a deployment token or permission to modify production. OpenAI's Codex security guidance treats filesystem, network, browser, and connected-tool authority as security boundaries because repository content or tool output can contain hostile instructions (Codex security).

The same rule applies between agents. A coordinator should pass the worker only the paths and tools needed for the delegated lane. “Everything, just in case” increases both distraction and blast radius.

Why the routing rubric exists

Routing is an economic and quality decision: which model should do which work, under which billing path, with how much review? The July audit describes Claude Code, Codex, and Grok Build as overlapping terminal agents with different strengths. Claude may win on judgment, long-context work, or design reasoning; Codex may win on repository implementation and the owner's bundled OpenAI capacity; Grok may win when current X discourse is material (AI-toolchain audit).

The portfolio's useful pattern is an expensive judgment model orchestrating more predictable bulk capacity. Claude handles the work where taste, decomposition, and acceptance judgment have the highest leverage. Codex/GPT-5.6 handles bounded repository reading, drafting, patching, and tests. Grok Build handles a research lane when live X context is actually needed, then primary sources verify the claims. The book's production outline already separates contract, draft, taste pass, fact check, and compile instead of asking one pass to be author, editor, and verifier (book outline).

“Flat-rate bulk” needs one correction. Subscription access is budgeted capacity with rate limits, not infinite compute. The audit says ChatGPT and Codex share usage limits and can continue through purchased credits; API-key use is billed separately. Claude subscriptions can likewise share limits and use extra credits, while the Fable model's documented post-launch path consumes purchased usage credits at API rates. Grok's consumer subscription, Build allowance, and API billing are separate paths (AI-toolchain audit).

At medium confidence, practitioners also describe token and credit management as ordinary solo-founder operations: set a budget per lane, reserve expensive context for judgment, and measure accepted work rather than celebrating raw token throughput (2026 agent-stack discussion).

The rubric therefore routes on more than model quality:

QuestionWhy it changes the route
Is this a taste decision or a bounded transformation?Judgment deserves the model and human attention that reduce rework; mechanical scope benefits from cheaper capacity.
Does the task require fresh X or web evidence?Grok's X Search is a tool that must be enabled, and X should lead to primary-source verification rather than serve as technical truth (xAI X Search docs).
Can the result be verified by a command?A bounded change with a deterministic check is safer to delegate than an open-ended architecture decision.
What permissions are required?A worker should not inherit production, browser, or account access unrelated to its lane (Codex security).
Which billing identity is active?Subscription login and API keys can produce different limits and charges (AI-toolchain audit).

The live plan, credit caps, and authentication path for all three CLIs were not verified in the audit. ABSENT — resolve by: record the active Claude, ChatGPT/Codex, and xAI plan; whether each CLI uses subscription login or an API key; shared limits; purchased-credit caps; auto-reload; and cost per accepted task. Until then, “cheap model” is a routing hypothesis, not measured unit economics.

The delegation contract

A delegation contract is the context the receiving agent needs to finish one bounded job without guessing the shape of “done.” It has six parts:

  1. Goal: the observable result, stated in product or artifact terms.
  2. Paths: the files or directories the agent may read and change.
  3. Constraints: required patterns, safety rules, format, and facts that must remain true.
  4. Non-goals: adjacent work the agent must leave alone.
  5. Test command: the executable check that should pass.
  6. Stop point: the exact moment ownership returns to the coordinator or human.

The Wave 2 book assignment is a real example. The goal is four named chapter files. The readable paths are the outline, Wave 1 chapter, audit, inventory, recipes, and decision record. The writable path is ./out/. Constraints include dated frontmatter, 1,800–2,800 words, real portfolio anchors, inline citations, one diagram brief, and the On your stack close. Non-goals include changing the source repo, Git operations, secret hunting, price re-research, and inventing users or metrics. The test is a structural and citation audit. The stop point is the requested per-chapter report.

That contract is why the worker can draft without inheriting every conversation that produced the book. It also gives the reviewer a checklist independent of how persuasive the prose sounds.

A weak handoff says “write chapter 9.” The agent must invent the audience, truth source, length, payment architecture, and completion condition. A strong handoff says the two payment authorities converge in Slate's app-owned ledger and names the adopted decision record. The agent can still make writing choices, but it cannot casually replace the architecture with a familiar Stripe-only pattern.

Why “it compiles” is not verified

Compilation proves a limited contract: source text satisfied the compiler and type checker well enough to produce an artifact. It does not prove the right feature was built, the browser can complete the flow, an unauthorized user is denied, a webhook rejects altered bytes, a refund changes only the correct grant, or the production environment contains the required configuration.

Tests also prove only what they exercise. A unit test for isPublicIp does not prove every arbitrary URL caller uses the guarded fetch primitive. A webhook test with a parsed JSON object does not prove the real route verifies the raw body. A mocked purchase does not prove sandbox and production identities reconcile. Verification must follow the actual boundary described by the claim (SSRF recipe, entitlement-ledger decision).

Use a ladder:

  1. Review the diff against the delegation contract.
  2. Run the narrow test that names the changed behavior.
  3. Run the broader type, lint, build, and test suite.
  4. Exercise the production-shaped flow across its real boundaries.
  5. Inspect the resulting data, logs, or response—not just the button click.
  6. Confirm that negative and failure cases fail for the intended reason.

For Slate's save path, “verified” includes a safe public URL saving successfully, private and redirected-private destinations being denied, oversized or slow responses stopping at their bounds, extracted HTML being sanitized, and the resulting row belonging to the signed-in user (SSRF recipe, shared-project recipe).

Verify the verifier

The verifier can be wrong too. A test command may skip the relevant directory. A browser script may assert only that a page loaded. A mock may reproduce the implementation's mistake. An agent reviewer may accept a familiar pattern without noticing that Slate uses a different one.

For meaningful changes, make one review adversarial by changing model family or vendor. The second model receives the contract and diff, then looks specifically for missing wiring, unsafe assumptions, and tests that merely mirror the implementation; 2026 practitioner reports treat that cross-family review as normal protection against long-session architecture sludge, not as proof by consensus (cross-model review, six-month PR audit).

“Verify the verifier” means challenge the evidence channel itself. Deliberately break the property and confirm the test turns red. Inspect test discovery output. Make one webhook signature invalid and confirm the handler rejects it before parsing. Point one RLS test at another user's row. Change one expected chapter heading and confirm the structure check fails. Then restore the correct implementation and watch the check return green.

This is mutation in miniature: if the verifier cannot detect a known bad state, its green result has little value. You do not need to sabotage every line. Choose the failure that would make the claim false.

The final acceptance decision still belongs to you. Models can propose, implement, critique, and run checks. The useful output of the whole system is not code that one agent praised. It is a small diff plus evidence that survived a different way of being wrong.

On your stack
  • Claude Code, Codex/GPT-5.6, and Grok Build are developer tools, not runtime dependencies of Slate, HMD, or Hestya (AI-toolchain audit).
  • Route expensive judgment and taste deliberately; send bounded bulk work only with a delegation contract and an executable stop point (book outline).
  • Treat subscription capacity as rate-limited and billing-path dependent, not unlimited flat-rate compute; verify the active logins, API keys, and credit caps (AI-toolchain audit).
  • Prefer project rules, skills, hooks, fresh bounded sessions, and cross-family review over trying to rescue every task with a longer prompt (verified practitioner audit).
  • Keep worker permissions scoped to the paths and tools the task requires, especially for network, browser, MCP, and production access (Codex security).
  • Accept a change only after the diff, targeted checks, real boundary traversal, negative cases, and verifier sanity check agree (book outline).

CHAPTER 05 · THE STACK

Hosting: where should your app live?

Why are you paying Vercel when Cloudflare advertises a free Worker, a Vultr server starts at a few dollars, and WordPress itself costs nothing?

Because those are not four prices for the same box.

They sell different divisions of labor. Vercel takes your application build and operates the web delivery path around it. Cloudflare Workers runs small programs inside Cloudflare's edge network, under an isolate runtime with strict meters. Vultr rents you a Linux machine; RunCloud helps you administer it. WordPress is publishing software that still needs a machine, a runtime, updates, backups, and usually a collection of plugins.

The cheapest-looking line item often leaves the most work outside the invoice. The useful hosting question is not “which logo costs less?” It is “which jobs does this price remove from your week, and which jobs does it hand back to you?”

This portfolio has already answered that question in practice. Its 31 active domains use Cloudflare for DNS. Its primary custom applications—including Slate at slatereader.com, HMD at healthymandaily.com, and Hestya at gethestya.com—are among 23 projects in one Vercel team. Cloudflare runs one production Worker, slate-email-ingest; D1 and KV are empty, R2 is not enabled, and no paid Cloudflare products were in use when the account was pulled (live account inventory, 2026-07-15).

That is not indecision. It is a split by job.

What each hosting invoice buys and which responsibilities remain yours WHAT THE INVOICE BUYS RESPONSIBILITYVERCELWORKERSVULTR + RUNCLOUDWORDPRESS DNS / TLS / CDNbuild + preview deploymentsapplication runtimeoperating-system patchingprocess supervisiondatabasebackups + restoreCMS / editor DNS external;TLS / CDN managedmanagedFUNCTIONS MANAGEDmanaged platformmanaged platformexternalexternalexternal Cloudflare networkmanagedadapter buildedge runtime managed;Next.js: OpenNext adaptermanaged platformmanaged platformexternalexternalexternal owned by Zachowned by ZachVM rented;runtime operatedowned by ZachRunCloud reduces work;does not remove itowned / externalowned by Zachseparate hosting separatehosting separatehosting separatemaintenance separatemaintenance separatehosting separatemaintenance separatePROVIDED managed means the provider operates that layer; it does not make adjacent layers disappear
What each hosting invoice buys and which responsibilities remain yours

The dated cost floor

These are public list prices from the adopted stack audit, not the owner's invoices. They are snapshots as of July 15, 2026, before taxes, add-ons, account-specific terms, and usage outside the listed allowances (stack audit).

PathPublic starting pointWhat that starting point includes
Vercel Pro$20/monthOne paid seat, $20/month usage credit, 10 million Edge Requests, and 1 TB Fast Data Transfer per month. Published overages include $2 per additional million requests and $0.15/GB transfer (Vercel pricing, Pro plan docs).
Workers Free$0100,000 requests per day and 10 ms CPU per invocation; static-asset requests are free and unlimited (Workers pricing).
Workers Paid$5/account/month minimum10 million requests and 30 million CPU-ms per month; published overages are $0.30/million requests and $0.02/million CPU-ms (Workers pricing).
Vultr + RunCloudAbout $14–$19/month before backupA representative 1–2 GB Vultr VPS lists at $5–$10/month; RunCloud Essentials lists at $9/month or $90/year for one server, unlimited web applications, 2 GB included backup storage, and one staging site (Vultr plans API, RunCloud pricing).
Self-hosted WordPress$0 core, hosting extraWordPress's software license is free; the server, control panel, premium plugins or themes, mail, security tooling, and offsite backups are separate (WordPress download).

The table is a starting line, not a verdict. Vercel meters more than requests and transfer. Workers meters compute under a different runtime. A VPS price does not include your operations time. “WordPress is free” describes a license, not a production system.

Vercel: the application platform

Vercel is the closest of the four to “give it the repository and run the web app.” For a Next.js project, it turns builds into immutable deployments, attaches preview URLs, manages TLS and CDN delivery, routes requests, and runs server-side code as Vercel Functions. The platform's CDN can answer from cache or continue to a function when the route needs code (Vercel CDN documentation).

This is why it fits Slate, HMD, and Hestya. You ship frequently with agents. A deployment should be a repeatable consequence of the repository, not a bespoke sequence of SSH commands. Vercel's first-party Next.js support also means new framework behavior arrives there before an adapter has to reproduce it.

The $20 Pro floor is not the whole billing model. Vercel moved Pro to a credit-based model with Spend Management in September 2025: the subscription includes usage credit, while metered products can continue into on-demand spend (Vercel announcement, verified practitioner audit). Fitting inside one allowance does not prove the project fits inside all of them.

The production-shaped billable meters are active CPU, provisioned-memory time, build minutes, data transfer, requests, and image transformations (Vercel pricing). Moving chatty or slow work into a function can multiply compute charges; uncached assets and responses consume transfer; middleware and edge-heavy designs turn ordinary page views into many requests. Preview deployments are not billed per deployment: their builds, traffic, and image work cost through those same meters. Practitioners repeatedly report meter usage—not an abstract “serverless premium”—behind surprise bills (function-duration report, Pro billing thread). At medium confidence, some audits find preview activity and image optimization dominate more than production traffic, so inspect usage before planning a migration (preview/image audit).

Spend Management is a circuit breaker, not a cost model. Vercel's documentation says a new Pro team can have a default $200 on-demand budget, and a configured cap can notify or pause production—which exchanges cost certainty for availability when the limit is reached (Vercel usage management). “Set a spend limit” is therefore insufficient: add meter-specific alerts, trace expensive routes back to code, exclude or prune unnecessary previews, and decide in advance which workloads may pause (cost-spike discussion, spend-surprise report).

That $200 default matters because it is ten times the $20 subscription line. It is also not proof of this account's setting. ABSENT — resolve by: open the Vercel team Usage and Billing settings, record the Pro plan, seats, current on-demand budget, notification thresholds, and whether reaching the cap pauses production. The live inventory confirms the team was upgraded to Pro during the July 15 session, but the CLI did not expose the plan or spend cap (live account inventory).

Vercel wins here when Next.js fidelity, automatic previews, managed rollouts, and low operations load are worth the platform floor. Its cost is partly a subscription and partly not having to make yourself the Linux administrator for 23 projects.

Its honest downside is coupling. If Slate adopts Vercel-specific function behavior, image handling, cron, analytics, storage, or edge configuration, moving is not just changing a DNS record. You must replace those behaviors. Convenience today becomes migration work later (Vercel Pro plan docs).

Cloudflare Workers: the bounded edge runtime

A Worker is not a small VPS. It is code that runs inside Cloudflare's network when a matching request or event arrives. Cloudflare creates lightweight isolates under a constrained runtime instead of giving you an operating system to administer. That is excellent for narrow HTTP work close to the network and a poor fit for code that assumes it owns a server.

The real portfolio example is slate-email-ingest. It has one job: turn an inbound email event into Slate's ingestion path. It does not try to become the Slate web interface, database, or publishing system. That is the “companion surface gets one bounded job” rule expressed as infrastructure. The Worker was the account's only Worker and remained on Free on July 15 (live account inventory).

Workers Free includes 100,000 requests per day, not per month. A quiet month does not bank unused requests for a busy day. Paid starts at a $5 account minimum and shifts the included request and CPU amounts to monthly meters. Cloudflare lists no egress charge for Workers compute traffic, although bound storage products have their own prices (Workers pricing).

The runtime's shape matters as much as the price. Workers isolates have a 128 MB memory limit. CPU-heavy libraries, large in-memory transformations, and server-shaped dependencies can fail even when request counts look inexpensive (Workers limits).

Cloudflare can host Next.js through OpenNext, but this is an adapter path. Cloudflare documents broad feature support while also requiring the nodejs_compat flag and listing Node.js middleware as unsupported. Every Next.js or adapter upgrade needs a compatibility check against the features the app actually uses (Next.js on Workers). Vercel removes more of that framework-integration work because it owns both the primary Next.js deployment target and the platform behavior around it.

Workers wins when the job is bounded request/response logic, global placement matters, and the runtime contract fits. It could become a Next.js home only after Slate, HMD, or Hestya passes a real OpenNext build and end-to-end test suite. A lower base price is not evidence of compatibility.

Vultr plus RunCloud: a machine, with help

Vultr rents virtual machines. A VPS gives you root access to an ordinary Linux environment. You can run Node, PHP, WordPress, cron, queues, databases, background workers, or custom binaries. That freedom is the product.

It also makes you the operations team.

The July audit lists Vultr Cloud Compute examples at $5/month for 1 GB and $10/month for 2 GB. Instances are billed hourly up to a monthly cap based on 672 hours, but stopping an instance does not stop billing; you must destroy it to release the allocated compute (Vultr plans API, Vultr billing). Backups, snapshots, block storage, overages, and tax can add cost.

RunCloud is a control plane for that server. It helps configure Nginx, PHP, TLS, deployments, databases, monitoring, staging, and backups. It does not become the host and it does not remove the single machine beneath the panel. Even RunCloud's Node guidance still leaves you installing Node, starting the application, and supervising the process (RunCloud Node.js guide).

With this path, you own operating-system and runtime updates, firewall rules, process health, disk pressure, capacity, backup retention, off-provider restore, and incident recovery. RunCloud lowers toil; it does not accept that accountability. Its backup guidance discourages local-only copies because a backup stored on the same failure domain is not enough (RunCloud backups).

A VPS wins when you truly need Linux: a long-running process, a custom binary, durable local state, private networking, or sustained work that has been measured and shown to be cheaper. It does not win merely because $5 is less than $20.

The current machine is not documented in the live inventory. ABSENT — resolve by: confirm whether a Vultr instance and RunCloud subscription are active, then record the region, plan, operating system, hosted sites, invoice, resource peaks, backup destination, and date of the last clean restore. Until that exists, no public sentence should claim which portfolio workload still lives there.

WordPress: publishing software with a maintenance cycle

WordPress is not a hosting category by itself. It is an open-source PHP content-management system that stores content and configuration in MySQL or MariaDB. On this comparison's self-hosted path, Vultr supplies the machine and RunCloud helps operate it; WordPress supplies the editor, themes, plugins, users, and publishing model.

That model wins when the work is conventional editorial publishing and the mature plugin, theme, and editor ecosystem saves more time than a custom Next.js interface. Several low-traffic sites can share one VPS economically. But sharing also combines their failure domain.

The maintenance cycle is real. WordPress's current requirements call for PHP 8.3 or greater, MySQL 8.0 or MariaDB 10.11 or greater, HTTPS, and Apache or Nginx (WordPress requirements). Core, PHP, the database, the active theme, and every plugin must remain secure and compatible. WordPress's own hardening guide treats the host, file permissions, database, plugins, administration, and backups as one security boundary (WordPress hardening).

Scheduled jobs need attention too. WP-Cron normally runs when site traffic triggers it, not as a guaranteed system scheduler, so critical jobs should move to a real system cron and be tested (WordPress Cron). One abandoned plugin can block a runtime upgrade. One failed update can affect the site. One full disk can affect every colocated site.

The inventory does not confirm whether any of the 31 domains still hosts WordPress. ABSENT — resolve by: identify every live WordPress installation, then record its host, WordPress/PHP/database versions, theme and plugin inventory, annual licenses, update procedure, isolation, and last tested restore.

Why this portfolio uses Vercel plus Cloudflare

The current split puts the hero applications where the Next.js workflow has the least friction and puts network utilities where Cloudflare has the narrowest useful job.

Vercel owns builds, previews, production deployments, CDN routing, and functions for Slate, HMD, Hestya, and the rest of the active web projects. Cloudflare owns authoritative DNS across all 31 zones, Email Routing, and one email-ingress Worker. Supabase remains the data and identity layer rather than being folded into either host (live account inventory). Each provider has a legible boundary.

You should revisit that default when evidence changes one of those boundaries:

Hosting is where your app lives, but it is also where responsibility lives. The winning platform is the one whose remaining responsibilities you can name, afford, test, and recover.

On your stack
  • The hero web apps—Slate, HMD, and Hestya—run as custom-domain projects on the 23-project Vercel team (live inventory).
  • Cloudflare's bounded jobs are authoritative DNS for 31 active Free-plan zones, Email Routing, and the single slate-email-ingest Worker; D1, KV, and R2 are not part of the live data path (live inventory).
  • Vercel Pro is credit-based, not “$20 flat”: watch function duration, Fast Data Transfer, Edge Requests, and preview activity alongside the on-demand budget (verified practitioner audit).
  • Do not count a VPS or WordPress as cheap until the active machine, licenses, patching, offsite backup, and tested restore are in the inventory.
  • Revisit Vercel plus Cloudflare only when measured cost, a real runtime requirement, or a real publishing workflow changes the current division of labor.

CHAPTER 06 · THE STACK

Domains, DNS, and email routing

If Cloudflare manages slatereader.com, Vercel hosts Slate, and email to Slate enters a Worker, which company actually owns each part of the address?

The domain is one name with several jobs attached. A registrar records who controls the registration. An authoritative DNS host publishes the instructions for that name. A web host serves the application those instructions point toward. A reverse proxy can stand between the visitor and that host. Email records can send mail for the same name into an entirely different system.

Cloudflare's dashboard can participate in three of those jobs, which is why the boundaries blur. In this portfolio, Cloudflare is authoritative DNS for 31 active zones. But the checked web records for Slate, HMD, and Hestya are DNS only: Cloudflare answers the address question, then the browser goes straight to Vercel. Cloudflare is also where Slate's inbound email route enters the one production Worker. Same account, different paths (live account inventory).

slatereader.com splits at authoritative DNS into direct web hosting and routed inbound email SLATEREADER.COM · TWO ROUTES FROM ONE DOMAIN registrar: ABSENT delegates Cloudflare authoritative DNSone of 31 active zones WEB PATH · DNS ONLY apex A · 76.76.21.21www CNAMEcname.vercel-dns.com browser connectsdirectly to Vercel Slate project Cloudflare proxy / CDN / WAF INBOUND MAIL PATH MX records Cloudflare EmailRouting catch-all Email Workerslate-email-ingest signed bounded JSONover HTTPS Slate/api/ingest exact privatealias Supabase row HEALTHYMANDAILY.COM · OUTBOUND NEWSLETTER INSETConvertKit DKIM / SPF CNAMEsexact record names + targets: ABSENTnewsletter sender
slatereader.com splits at authoritative DNS into direct web hosting and routed inbound email

The registrar controls the registration

A registrar is the company through which a domain is registered. It maintains the customer relationship around renewal and submits registration data to the registry for the top-level domain. ICANN accredits registrars for generic top-level domains and describes registries and registrars as separate parts of the domain-name system (ICANN accredited registrars).

The registrar is where losing access can mean losing the name itself. Renewal, account recovery, transfer locks, and the nameserver delegation live at this layer. Changing the registrar does not require changing the app's code. Changing the nameservers can change which DNS provider is allowed to answer for the domain.

The July 15 live inventory did not check the registrar or renewal state for any of the 31 zones. ABSENT — resolve by: run a registrar/RDAP sweep for all 31 domains, confirm the controlling account and renewal date for each, then record auto-renew, payment method, transfer lock, two-factor authentication, and recovery ownership without copying credentials into the book. (live inventory)

That absence is operationally different from a DNS absence. The applications were reachable and Cloudflare was authoritative when checked. The unknown is who controls renewal and delegation upstream (live inventory).

The authoritative DNS host publishes the instructions

DNS is a distributed directory. The registrar-side delegation tells resolvers which nameservers are authoritative. Those authoritative servers publish records: A and AAAA records for addresses, CNAMEs for aliases, MX records for inbound mail, TXT records for text-based policy and verification, and other specialized types. Cloudflare explains that using it as primary DNS requires the domain's authoritative nameservers to point to Cloudflare (Cloudflare nameserver concepts).

All 31 active portfolio zones were in the Cloudflare account on its Free plan on July 15, 2026. The inventory was pulled from the live Cloudflare API, correcting an earlier count of roughly 29 (live inventory). This proves Cloudflare is the DNS control plane for those zones. It does not prove Cloudflare receives their web traffic.

One zone can publish several independent routes. slatereader.com can point web requests at Vercel, mail exchange at Cloudflare Email Routing, and domain-verification tokens at other services. DNS does not carry the later web page or email body. It answers with the records that tell the next system where to go (Cloudflare DNS record types).

TTL, the time to live, tells caches how long they may reuse a DNS answer. A change can be correct at the authoritative server while some resolvers still hold the earlier answer until its cache lifetime ends. That is why a DNS change can appear inconsistent by location or network without either application host being broken (Cloudflare TTL documentation).

DNS host and reverse proxy are separate switches

Cloudflare can answer DNS in two modes for eligible web records. A Proxied record returns Cloudflare addresses, so HTTP traffic enters Cloudflare's network before reaching the origin. A DNS-only record returns the origin address, so Cloudflare answers DNS but does not carry the HTTP request (Cloudflare proxy-status documentation).

For a Vercel-backed hero app, DNS only is the deliberate default. Orange-cloud proxying creates a double CDN/security path that can add latency, complicate cache and TLS diagnosis, and blind parts of Vercel's bot protection (Guillermo Rauch on DNS-only, bot-protection follow-up, practitioner discussion). At medium confidence, proxying can reduce some Vercel bandwidth cost, but that is an explicit cost-versus-platform-protection trade—not a free performance layer (bandwidth tradeoff).

The distinction was checked live because the portfolio notes had said Cloudflare was “proxying to Vercel.” For slatereader.com, healthymandaily.com, and gethestya.com, the API reported proxied: false. Their apex records point at Vercel's 76.76.21.21, and their checked www records use cname.vercel-dns.com. Cloudflare's CDN, WAF, cache, and HTTP rules are therefore not in the request path for those hero apps. The browser connects straight to Vercel after resolving the name (live inventory).

Do not widen that finding beyond its evidence. The inventory verified authoritative DNS for all 31 zones, but it did not bulk-pull proxy status for the remaining zones. ABSENT — resolve by: export all DNS records across all 31 Cloudflare zones and record every proxied flag, origin target, mail route, stale record, and duplicate verification record. (live inventory)

This matters during an incident. A Cloudflare proxy rule cannot explain a Slate page failure when the Slate record is DNS only. Cloudflare DNS can still explain a wrong destination or failed name lookup. Vercel routing, TLS, functions, and deployment state begin after that boundary.

Email uses the same name and a different route

Mail servers look for MX records rather than the web A or CNAME route. An MX record identifies the servers responsible for accepting mail for a domain. Cloudflare Email Routing can receive that mail and forward it to verified destination addresses; it is routing, not a stored mailbox, help desk, archive, or ordinary outbound SMTP service (Cloudflare Email Routing overview, stack audit).

Forwarding is also not guaranteed inbox placement. Founders reported Cloudflare-routed mail being accepted and then placed in Gmail or Microsoft spam during 2025, so any alias used for support, recovery, or product intake needs real destination tests and monitoring rather than “route active” as its health check (Gmail report, Microsoft report).

Simple aliases can forward support@ to an existing inbox. Slate uses the same ingress point for a product action instead. Its Cloudflare Email Routing catch-all hands accepted messages to an Email Worker named slate-email-ingest. The live account had exactly one Worker, created July 14 and modified July 15, and no D1 or KV databases; R2 was not enabled (live inventory). The Worker is a narrow translator, not a second Slate backend.

Cloudflare's route lives outside the Worker's wrangler.jsonc, while workers_dev is disabled, so the Worker has no public workers.dev HTTP surface. Email invokes its email handler; an arbitrary web request does not (email-ingress recipe).

Turning an address into an API call

Slate gives a user a private, rotatable inbound alias. The alias contains 128 random bits encoded into the address shape used by the product. That address is a capability: knowing it permits someone to attempt to add mail to that user's reading queue. It is not proof that the sender named in the message is genuine (email-ingress recipe).

When Cloudflare invokes slate-email-ingest, the Worker reads the SMTP envelope recipient. That is the routing address the mail system actually accepted. It does not authorize from the visible MIME To: header, which a sender can write freely. Bad recipient shapes are dropped before expensive parsing (email-ingress recipe).

The Worker then bounds the work. Slate rejects raw messages above 5 MiB, limits MIME nesting to 32 levels and headers to 256 KiB, caps decoded content at 1,000,000 code units, and excludes attachments from the JSON payload. These are the implemented Slate limits recorded against commit 7b77916, not recommended universal defaults (email-ingress recipe).

After parsing, it builds a deterministic event identity. A Message-ID is namespaced by normalized envelope recipient, so the same upstream ID delivered to two Slate aliases does not collapse into one user's event. The Worker serializes the small payload once, signs the timestamp plus the exact JSON bytes with HMAC, includes a non-secret key ID, and sends the body to Slate's app endpoint with a ten-second fetch timeout (email-ingress recipe).

That HTTPS request is the moment an email address becomes an API call. It is not trusted merely because it came from the Worker URL. Slate's server streams at most 7 MiB, selects the current or configured previous HMAC key, checks the signature against the exact raw bytes, permits at most five minutes of age and 30 seconds of future clock skew, and parses JSON only after verification (email-ingress recipe).

The app then resolves one exact stored alias to auth.users, enforces the implemented entitlement and rate limits, sanitizes HTML, and enters a per-user database lock. Inside one transaction it rechecks limits, inserts the idempotency event, and creates the queue item only if the event was new. Slate's real policy is 60 email ingests per hour and 200 per day, with the first-20-items trial also enforced on this path (email-ingress recipe).

Every boundary answers a different question. MX and Email Routing answer where the mail enters. The random envelope alias answers which Slate user may be selected. HMAC answers whether the request bytes came through the trusted Worker path recently. Sanitization answers what content may be stored. The database transaction answers whether a duplicate or concurrent request creates another item.

Why a friendly address is not enough

An inbound email address is exposed to a hostile network. Messages can be oversized, deeply nested, duplicated, or forged. A readable address such as read@ would also be guessable. Slate's alias is private and rotatable so exposure can be revoked without redesigning the account.

The Worker logs only keyed pseudonymous references, raw byte count, duration, and status—not the address, subject, body, or Message-ID. That makes operations visible without turning logs into a second copy of private mail or the capability address (email-ingress recipe).

If ingestion must be stopped, the routing layer is the clean kill switch: remove or drop the catch-all route. Weakening signature or alias checks to get mail flowing would erase the boundaries that make the feature safe.

HMD's newsletter records go outward

HMD at healthymandaily.com provides the portfolio's real outbound-mail contrast. Its Cloudflare zone carries ConvertKit DKIM/SPF CNAMEs for the newsletter sending domain, while its web apex and www records remain DNS only to Vercel (live inventory). The mail authentication records do not host the HMD website and do not send a browser through ConvertKit.

Those CNAMEs delegate or point the verification/authentication names expected by the newsletter provider. DKIM lets a receiver verify a cryptographic signature associated with the sending domain. SPF publishes which infrastructure is authorized to send for a domain or delegated return-path domain. Chapter 10 separates those proofs from DMARC policy (live inventory, RFC 6376, RFC 7208).

The live inventory recorded the presence and provider purpose of HMD's ConvertKit records, not their exact owner names, targets, alignment, or current verification result. ABSENT — resolve by: export healthymandaily.com DNS, identify each ConvertKit SPF and DKIM CNAME by host and target, record the visible From and return-path domains, check provider verification, and capture the DMARC policy without publishing any account token. (live inventory)

The complete map is therefore not “Cloudflare runs the domains.” Cloudflare publishes DNS for 31 zones. It receives Slate mail through Email Routing and runs one bounded Worker. Vercel receives the checked hero websites directly. ConvertKit sends HMD's newsletter under DNS-published authority. The registrar layer remains to be inventoried.

On your stack
  • All 31 active portfolio zones use Cloudflare authoritative DNS on the Free plan; registrar identity and renewal controls remain unverified (live inventory).
  • Slate, HMD, and Hestya are DNS only to Vercel—proxied: false—so Cloudflare's proxy/CDN/WAF is not on those applications' checked HTTP paths (live inventory, Cloudflare proxy status).
  • Slate inbound mail follows Cloudflare Email Routing → slate-email-ingest → signed bounded JSON → Slate's authenticated ingest route; the Worker is not a public HTTP endpoint (email-ingress recipe).
  • Treat Slate's private inbound alias as a rotatable write capability, and preserve envelope routing, byte/MIME limits, HMAC freshness, sanitization, rate limits, and atomic idempotency as one contract (email-ingress recipe).
  • HMD's Cloudflare DNS contains ConvertKit DKIM/SPF CNAMEs for newsletter sending, but the exact records, alignment, DMARC policy, and live verification still need an exported record-level audit (live inventory).

CHAPTER 07 · THE STACK

The database: Postgres, Supabase, and why RLS matters

When Slate saves an article, what is the database actually doing—and what stops one signed-in person from reading somebody else's queue?

The short answer is not “Supabase handles it.” Supabase gives you a managed system with useful parts already connected. The durable data still lives in Postgres. Slate's server still opens a connection and sends a query. Postgres still decides which table, which rows, and which operation the query may touch.

That last decision is where Row Level Security, or RLS, earns its place. A normal table permission can answer, “may this role select from items?” RLS answers the more important product question: “which rows in items may this identity select?” PostgreSQL applies those policies inside the database as part of executing the query (PostgreSQL row-security documentation).

This is no longer a sidebar risk. The verified 2025–26 practitioner audit ranks missing or weak Supabase RLS as the number-one production failure mode in agent-built apps: public clients can turn an absent policy into cross-account reads or writes at database speed (verified practitioner audit, repeated Supabase warning). For the disputed CVE-2025-48757, a Lovable/Supabase authorization failure, MITRE's CNA rates the issue 9.3 Critical; NIST/NVD has not assigned its own score. The vendor disputes the record on the grounds that customers own protection of their application data. That named CVE is one public example, not permission to assume other builders are safe.

For Slate, the rule is concrete: a user-owned row carries user_id, and the allowed identity must match it. That pattern appears in the actual migrations, not just in an architecture diagram (Slate shared-project recipe).

A Slate save request crosses SSRF defenses into one shared Supabase project ONE SLATE “SAVE ARTICLE” REQUEST browserauthenticated session Vercel server code USER-SUPPLIED URLSSRF-guarded fetch+ extraction boundary pooled SLATE_DB_URL ONE SUPABASE PROJECTshared Auth, compute, quotas, and outage boundary shared auth.users other schemaseparate app schema SLATE SCHEMA items · user_idRLS SHIELDauth.uid() = user_id prefs · user_idRLS SHIELDauth.uid() = user_id ingest_events · user_idCLOSED SHIELD · RLS on, no policiesservice-only entitlementsuser_idaliasesuser_id
A Slate save request crosses SSRF defenses into one shared Supabase project

A table is a promise about data

You can think of a Postgres table as a named collection of rows with an enforced shape. Each column describes one part of a row. In Slate's items table, that shape includes an ID, the owning user_id, the source URL, and the fields Slate needs to represent the saved item. The original migration also makes (user_id, url) unique, so one person's queue cannot accumulate the same URL twice under that rule (20260713_slate_v0.sql in the shared-project recipe).

This is more than a spreadsheet with an API. Postgres can enforce types, required values, unique combinations, and references between tables. Slate's user_id references auth.users(id). If the app tries to write a row for an identity that does not exist, the foreign key can reject it. If the identity is deleted, the migration's chosen deletion behavior tells Postgres what should happen to related rows. These are database guarantees, so they apply no matter which server function or agent-written code sent the query.

A schema is one namespace above a table. It lets the database contain slate.items without confusing it with another app's items. Slate qualifies its objects with the slate schema instead of relying on a mutable default search path (shared-project recipe). That prevents name collisions and wrong-table mistakes. It does not, by itself, decide who may read a row.

Postgres also has indexes. An index is an additional structure that helps the database find rows without scanning every row in a table. It costs storage and extra work when data changes, so you add one for real query patterns rather than as decoration. Slate's original migration includes its schema, tables, constraints, and indexes together in supabase/migrations/20260713_slate_v0.sql, which keeps the code that expects the structure paired with the change that created it (shared-project recipe).

When the table promise breaks, the database often fails loudly. A query names a column that a deployment has not migrated yet. A unique constraint rejects a duplicate. A foreign key rejects an orphan. Loud failure is useful: it stops inconsistent data from becoming a quiet product bug.

A query is a precise request

SQL is the language the server uses to ask Postgres for work. A select reads rows. An insert creates one. An update changes existing rows. A delete removes them. Conditions narrow the target, joins connect related tables, and a transaction groups changes so they succeed or fail as one unit. PostgreSQL's own tutorial introduces tables, rows, queries, joins, updates, deletions, foreign keys, and transactions as parts of the same relational system (PostgreSQL tutorial).

Slate's server does not say, “database, give me the app.” It sends narrow operations against schema-qualified objects such as slate.items and slate.prefs. The result comes back as rows, which server code turns into the page or API response the browser needs (shared-project recipe).

That wording matters when you debug. “Supabase is slow” may really mean one query is missing an index, one function is waiting for a connection, or one route is asking for more rows than it uses. The database is not one opaque call. It parses a specific statement, plans how to execute it, checks access, performs the work, and returns a specific result.

The real Slate save path starts even earlier. A person supplies an article URL, so the server first passes that arbitrary destination through Slate's bounded SSRF fetcher. It rejects unsafe destinations, validates DNS answers, pins the connection, rechecks redirects, and limits time and bytes before extraction. Only the returned content moves downstream to the save logic (SSRF-guarded extraction recipe). The database should store an allowed product result, not become a scrapbook for unbounded network responses.

A connection is a scarce conversation

A query needs a live conversation between a client and Postgres. Opening that conversation involves network and authentication work, and an open connection consumes database resources even while it is waiting. A long-lived server can keep a small pool and reuse its connections. Serverless functions can scale into many short-lived instances, so opening a fresh direct connection for each invocation can exhaust the database before the queries themselves are difficult.

Supabase offers direct, session-pooled, and transaction-pooled connection modes. Its guidance describes direct connections for persistent backends and administrative tools, and transaction pooling for temporary clients such as serverless or edge functions. A server-side pooler keeps database connections warm and shares them among transient clients (Supabase connection guide).

Slate uses a server-only SLATE_DB_URL with the postgres client and an application pool capped at max: 2. That number is a real Slate choice, not a universal setting (shared-project recipe). The small cap prevents one function instance from greedily opening many database conversations. It does not guarantee the whole deployment can never open more than two, because multiple server instances can each create a pool.

When connections break, the symptom can look like random slowness. One request waits for a pool slot. A burst creates more server instances. The database reaches its connection ceiling and refuses another client. The query may be perfectly indexed and still never begin. That is why connection usage is a separate thing to observe from query duration.

What Supabase adds to Postgres

Every Supabase project gets a full Postgres database. Supabase then places managed services around that foundation: Auth, Data APIs, Storage, Realtime, Edge Functions, a dashboard and SQL editor, connection strings and poolers, and plan-dependent backups. As of July 16, 2026, Free excludes automatic backups; Pro includes daily backups with seven-day retention (Supabase pricing). Supabase's database overview explicitly describes Postgres as the foundation on which Auth, Storage, Realtime, and Edge Functions are built (Supabase database overview).

These additions remove assembly work, but they do not all become part of every app's path. Slate uses Supabase Auth for identity and Postgres for application data. Its browser client uses the public Supabase URL and anon key for Auth. Slate keeps its application schema off the Data API surface and accesses it from Next.js server code through SLATE_DB_URL (shared-project recipe).

Supabase Auth is project-wide. Its core user tables live in the auth schema, and its tokens carry identity information that database policies can use. Separate application schemas do not create separate identity systems, redirect settings, email templates, providers, or rate limits (Supabase Auth architecture). That is one reason the shared project saves a project fee but does not give Slate an independent backend.

The Data API is another choice, not a synonym for the database. Supabase can expose selected schemas through REST or GraphQL, and client libraries can call those APIs with the user's session. Custom schemas must be explicitly exposed and granted. Exposing one is a security decision because it creates a direct client-to-database surface governed by roles and RLS (Supabase custom-schema guidance). Slate currently avoids that surface for its app data.

The shared-project pattern

Slate shares a Supabase project with another app but owns the slate Postgres schema. This saves the cost and management of another project while preventing table-name collisions. Every Slate query names its schema. Every user-owned row points back to the common auth.users identity. Migrations extend the schema additively instead of editing applied history (shared-project recipe).

The first migration, supabase/migrations/20260713_slate_v0.sql, creates the slate schema, user-owned items and preferences data, indexes, RLS policies, and the service-only ingest ledger. The second, supabase/migrations/20260714_slate_w3_entitlements_aliases.sql, adds paid-access and email-alias tables inside the same schema. Those filenames are the chronological record of what changed and when; the recipe ties them to commits 2cab274 and a1008c5 respectively (shared-project recipe).

The separation is useful, but call it what it is: namespace isolation. Supabase's own guidance says integrated services target the default database and recommends another project when true database separation is required (Supabase database-separation note). Slate still shares compute, Auth, quotas, configuration, backups, and incident blast radius with the other app. A migration that exhausts resources or a project-wide Auth misconfiguration does not respect the visual boundary between schema names.

That is why “one schema per app” and “one project per app” solve different problems. The first prevents naming collisions and organizes privileges. The second creates a stronger operational and commercial boundary. The stack audit recommends treating a sale, multi-user need, incompatible Auth settings, quota pressure, or unacceptable blast radius as reasons to evaluate extraction into a dedicated project (stack audit).

The live Supabase account was not pulled during the July 15 inventory. ABSENT — resolve by: run supabase projects list or inspect the dashboard, then record the project, plan, region, compute, disk, exposed schemas, shared apps, Auth settings, connection roles, backup state, and current usage.

RLS: who can see which rows

RLS attaches policies to a table. Supabase offers a helpful mental model: a select policy behaves like an automatic where clause added to every query. A policy comparing auth.uid() with user_id makes another person's rows fail that condition inside Postgres, even if application code asks too broadly (Supabase RLS guide).

Slate enables RLS and writes policies for each required operation. Its update policy checks both the row being selected for change and the row after the proposed change:

create policy slate_items_update on slate.items for update
  to authenticated using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

That is real code from supabase/migrations/20260713_slate_v0.sql. using prevents you from targeting a row you do not own. with check prevents you from changing an allowed row so it claims a different owner. Equivalent select, insert, and delete policies cover the other operations; preferences repeat the ownership pattern (shared-project recipe).

RLS is enforced by Postgres, not by a hidden button or a filtered React array. Removing an item from the interface does not revoke access. Adding where user_id = ... in server code is useful intent, but the database policy is the boundary for roles subject to RLS.

Postgres uses default denial when RLS is enabled and no applicable policy exists. Slate uses that deliberately for slate.ingest_events, the machine ledger that records ingestion idempotency. The table has RLS enabled and no client policy, so ordinary authenticated clients do not receive a user-facing path into it (PostgreSQL row-security documentation, shared-project recipe).

RLS also has privileged bypasses. PostgreSQL superusers and roles with BYPASSRLS bypass row policies, and table owners normally do unless they force RLS (PostgreSQL row-security documentation). Supabase's service role is similarly intended for trusted server work and bypasses RLS (Supabase RLS guide). That power is why a server secret never belongs in the browser and why server routes must still verify the caller before privileged queries.

Views are another classic footgun. A view does not automatically inherit the caller's RLS behavior: creator-privileged views can bypass policies on their underlying tables unless they are deliberately configured for invoker security, kept off the exposed API, or granted narrowly (view warning, verified practitioner audit). Treat every view as a new read surface and test it with the same two-user matrix as its base tables.

The recipe establishes that Slate's data connection is server-only, but it does not record the live database role and whether each query is subject to RLS. ABSENT — resolve by: inspect the role encoded by SLATE_DB_URL, list its grants and BYPASSRLS/ownership behavior, inventory every exposed view, then run positive owner, negative cross-user, unauthenticated, and service-only denial tests through every real table and view access path.

The distinction is precise: RLS is the database-enforced row boundary when the querying role is subject to it. A privileged server path can cross that boundary by design, so its authorization check becomes part of the trusted computing base. “We enabled RLS” is not enough without knowing which role actually runs the query. At medium confidence, practitioners also warn that scanners which check only the enabled flag can miss permissive or incomplete policies; policy correctness requires executable negative cases (policy-scanner warning).

What breaks, and where to look

If every query fails, check the connection string, network reachability, credentials, pool capacity, and project health. If one query fails after a deploy, compare the code with the applied migrations. If one user sees no rows, inspect the session identity and policy condition. If one user sees another user's row, stop: test the role, grants, policy coverage, and server authorization before changing the interface.

If only ingestion duplicates, look at the ingest_events ledger and idempotency path. If saved content is unsafe or unbounded, the problem began before Postgres, at the outbound fetch and extraction boundary. If several apps degrade together, the shared project's compute or configuration may be the common failure domain.

Once you see those boundaries, “the database” stops being one green Supabase box. It becomes a set of enforceable promises: table shape, query scope, connection capacity, schema ownership, role privileges, and row policy. Agents can write all of them. Postgres is the part that must enforce them after the agent session is gone.

On your stack
  • Slate owns the slate schema inside a shared Supabase project; schema qualification prevents collisions, while shared Auth, compute, quotas, and outages remain project-wide (shared-project recipe).
  • supabase/migrations/20260713_slate_v0.sql creates the original user-owned tables, ownership policies, indexes, and service-only ingest ledger; 20260714_slate_w3_entitlements_aliases.sql extends the schema additively.
  • Every Slate user-owned row carries the shared Auth user_id; database policies compare it with auth.uid() for the operations the client role may perform.
  • Slate's browser uses public Supabase Auth configuration, while application queries use the server-only pooled SLATE_DB_URL; the live connection role still needs explicit verification.
  • Treat table and view RLS tests as executable authorization evidence: allow the owner; deny another user and anonymous access; deny ordinary clients from service-only data; verify privileged server routes separately (verified practitioner audit).

CHAPTER 08 · THE STACK

Auth: what a login actually is

After you sign in once and Slate keeps knowing who you are, what is the browser carrying—and why does the database believe it?

A login is not a permanent state inside the page. It is a ceremony that produces temporary evidence. The browser presents that evidence on later requests. Trusted code validates it, recovers an identity, and then decides what that identity may do.

Those are two separate questions. Authentication asks, “which identity is making this request?” Authorization asks, “may that identity perform this action on this row?” Supabase Auth can establish the identity and issue tokens. Slate's server code and Postgres policies still have to enforce access to Slate data (Supabase Auth architecture, shared-project recipe).

What survives sign-in from Google identity to authorized Slate data access WHAT SURVIVES SIGN-IN BROWSERIDENTITY SYSTEMSSLATE DATA click “Sign inwith Google” redirect Google return Supabasecallback access token +refresh token persist session send session evidencewith later Slate request Supabase AuthGoogle authorization+ authenticationauth codetoken exchangeSupabase user inshared auth.usersSupabase JWT magic link: email link → one-timeverification → same session-issuance point Vercel servervalidates identityserver-only pooledSLATE_DB_URLschema-qualifiedslate.items queryauthorization / RLSagainst user_id FIREWALL · public Supabase URL / anon key for Auth≠ server-only database URL for app data
What survives sign-in from Google identity to authorized Slate data access

A session is the continuing relationship

Signing in would be unbearable if every click required entering credentials again. A session lets the application recognize a browser across requests. It begins after a successful sign-in and ends through sign-out, expiry, revocation, or a configured session rule.

There are two common shapes. In a traditional server session, the browser holds an opaque random identifier and the server looks up the associated state. In a token-based session, the browser holds signed evidence containing claims that a service can validate. Supabase uses access and refresh tokens: the access token is a JWT, and the refresh token is a unique string used to obtain a new pair (Supabase sessions).

The access token is short-lived by design. Supabase's current session documentation says access tokens are usually configured between five minutes and one hour, while refresh tokens rotate as single-use credentials. Two recovery exceptions matter: the same token may be reused inside the default ten-second reuse interval, and using a parent token can recover its active child when the client never stored the newly issued token. The client library can persist, refresh, and remove those tokens for the app (Supabase sessions, Supabase Auth architecture). Those are platform behaviors, not proof of Slate's live timeout configuration.

“Remember me” therefore does not mean the original Google or email proof is replayed on every route. It means the browser retains session material that can maintain the relationship according to the Auth server's rules.

A cookie is a small name/value field that a server asks a browser to store and return on matching HTTP requests. Its attributes constrain where and when it is sent. Secure restricts transmission to HTTPS. HttpOnly prevents JavaScript from reading it through the browser API. SameSite controls important cross-site sending behavior. Domain and path scope which destinations receive it (MDN, “Using HTTP cookies”).

A cookie is not automatically a session, and a session is not automatically a cookie. A cookie can carry an opaque session ID, a JWT, or unrelated preferences. A native app may carry a bearer token in an authorization header instead. The useful question is not “does it use cookies?” but “what credential is stored, which code can read it, where is it sent, how is it refreshed, and how is it revoked?”

Cookie attributes reduce particular risks; they do not turn a stolen session into harmless text. If a browser sends a valid bearer credential, the server generally treats the holder as the signed-in identity. That is why auth tokens do not belong in URLs, analytics payloads, screenshots, or logs.

A JWT is signed claims, not encrypted identity

JWT means JSON Web Token. It is a compact format containing a header, claims, and a signature. Claims can include the issuing authority, the intended audience, expiry, subject identity, role, and a session identifier. The signature lets a verifier detect modification when it uses the correct key and validation rules (RFC 7519).

The common mental trap is to treat a JWT as a secret encrypted box. Its payload is normally encoded, not encrypted. Code holding the token can decode its claims. Trust comes from verifying the signature and required claims—not from the text looking unreadable.

Verification must include more than “the signature parses.” The verifier needs the expected issuer and audience, an allowed signing algorithm, current key, and time validity. The sub or equivalent identity claim tells trusted code who the token represents; authorization policy still decides what that subject may reach.

Supabase Auth is responsible for validating, issuing, and refreshing JWTs and for communicating with external social-login providers. Its Auth data lives in the project's auth schema, which is shared with the same project's other Supabase services (Supabase Auth architecture). In Slate, that shared Auth user ID becomes the user_id foreign key on user-owned rows (shared-project recipe).

Why plaintext passwords never belong in the database

A password is a reusable secret a person knows. If a service stores the plaintext, anyone who gains database read access gains every password immediately, including passwords reused elsewhere. Encryption alone still leaves a decryption key that can expose the whole collection.

Password systems store a salted, deliberately expensive one-way password hash and verify a candidate by running the same password-hashing function. OWASP recommends purpose-built password hashing such as Argon2id, scrypt, bcrypt for legacy constraints, or PBKDF2 where required, with current work factors chosen for the environment (OWASP Password Storage Cheat Sheet). A general fast hash such as bare SHA-256 is not a password-storage design.

The application also should not write passwords into logs, error reports, analytics, email, or its own profile tables. With managed Auth, Slate delegates the credential ceremony to Supabase Auth and external providers rather than creating a password column inside slate.items or slate.prefs. The shared-project recipe records Slate's use of Supabase Auth but does not claim which sign-in methods are live (shared-project recipe).

What “Sign in with Google” does behind the redirect

The button does not hand Slate your Google password. It sends the browser into a standardized redirect flow. Google authenticates the Google account on Google's origin and presents consent as required. The application identifies itself with a client ID, requests defined scopes, and supplies a pre-registered redirect URI (Google OAuth web-server flow).

For sign-in, OpenID Connect adds an identity layer on OAuth 2.0. Google can return an authorization code to the approved callback. Trusted backend/Auth code exchanges that short-lived code at Google's token endpoint and validates the returned identity information. The ID token's signed claims identify the Google account to the client; access tokens authorize calls to requested Google APIs and are not interchangeable with an application's own session (Google OpenID Connect).

The redirect URI must match the configured value. Google requires cross-site-request-forgery prevention and documents generating and validating state as one method (Google OAuth web-server flow). Separately, Supabase documents PKCE as binding the authorization request to the later code exchange (Supabase PKCE flow).

With Supabase social login, Supabase Auth is the intermediary. It communicates with Google, connects the external identity to a Supabase user, and issues the Supabase session tokens Slate understands (Supabase social login, Supabase Auth architecture). Google is proving the Google identity to Supabase. Supabase is issuing the application identity. Slate is authorizing that application's identity against Slate data.

The live inventory did not inspect the shared Supabase project, and the recipe does not enumerate Slate's enabled providers, client IDs, redirect allowlist, or current OAuth flow. ABSENT — resolve by: inspect the Supabase Auth provider settings and Slate sign-in implementation; record enabled methods, exact production and preview redirect allowlists, PKCE/state handling, identity-linking rules, and tested sign-in/sign-out/revocation paths without exposing client secrets. (live inventory)

A magic link replaces the password prompt with possession of a short-lived email-delivered link. The browser asks Auth to send a link to an address. Auth creates a token or code, places it in a URL pointing at an allowed callback, and sends the message. Opening the link returns the proof to Auth, which verifies it and creates the same kind of application session (Supabase passwordless email logins).

This does not make email disappear from the trust chain. Whoever controls the inbox while the link is valid can use the link. Redirect allowlists, rate limits, expiry, one-time use, email deliverability, and protection from link scanners all affect the real flow. Supabase documents magic links as single-use, with configured expiry and request throttling in the project (Supabase passwordless email logins).

OAuth and magic links reach a similar endpoint by different proof. Google says an external identity authenticated. A magic link says the actor controlled delivery to an email address for that ceremony. Afterward, Slate still needs a Supabase user identity and session (Supabase social login, Supabase passwordless email logins).

Slate's public Auth client is intentionally public

Slate's browser-facing Supabase client contains the public project URL and anon key and is used for Auth. “Public” does not mean “unrestricted database administrator.” It means those values are designed to appear in the client; authorization depends on the user's session, API grants, and RLS rather than on hiding the anon key (Supabase API keys, shared-project recipe).

Slate makes a stronger boundary for application data. Its slate schema stays off the PostgREST/Data API surface, and Next.js server code queries schema-qualified tables through a separate pooled SLATE_DB_URL. That connection string remains server-only. The browser client manages Auth; the server connection manages trusted data access (shared-project recipe).

Every user-owned Slate row references the shared auth.users(id). RLS policies compare the current Auth identity with user_id for client-governed access. Server routes using privileged database authority must separately validate the caller and constrain their query, because a privileged role may bypass RLS (shared-project recipe, Supabase RLS guide).

This is the answer to “why does the database believe the browser?” It should not believe the browser's claim by itself. Auth verifies session evidence and yields a trusted identity. The data path carries that identity into an authorization decision. The database enforces row policy when the querying role is subject to it; trusted server code enforces authorization when it uses broader authority.

Where login failures actually live

If the button never reaches Google, inspect browser code, provider configuration, and the initial redirect. If Google reports a redirect mismatch, compare the exact scheme, hostname, path, and provider allowlist. If the callback succeeds but Slate returns to login, inspect code exchange, cookies/token persistence, and session refresh.

If the session identifies the right user but Slate shows no items, move past authentication. Inspect the server's identity propagation, database connection role, user_id, and RLS policy. If another user's item appears, treat it as an authorization incident, not a cosmetic query bug.

Shared Supabase Auth also means project-wide settings can affect more than one app schema. Providers, redirect URLs, SMTP, templates, JWT configuration, rate limits, compute, and outage blast radius are not made independent by naming one schema slate (stack audit). That is the trade: one managed identity system with less setup and a wider shared boundary.

On your stack
  • Slate uses Supabase Auth as its identity system; Supabase Auth validates and refreshes sessions, communicates with social providers, stores identity data in the shared auth schema, and issues JWTs (Supabase Auth architecture).
  • The browser uses Slate's public Supabase URL and anon key for Auth, while application-data queries use server-only SLATE_DB_URL connections to schema-qualified tables (shared-project recipe).
  • Every user-owned Slate row carries user_id; authentication supplies that identity, while RLS or a trusted server route must still authorize the requested row and operation (shared-project recipe).
  • “Sign in with Google” is a redirect, consent/authentication, callback, code exchange, external-identity validation, and Supabase-session issuance—not a transfer of the Google password to Slate (Google OAuth flow, Supabase social login).
  • The live provider list, redirect allowlist, session settings, SMTP, templates, identity linking, and revocation behavior remain unverified project-wide configuration and need an exercised Auth inventory (stack audit).

CHAPTER 09 · THE STACK

Money: Stripe, RevenueCat, and the one-ledger rule

If Slate can sell the same lifetime access on the web and through Apple, which company gets to decide whether the signed-in account is paid?

Neither company gets the final read.

Stripe is the authority for a Stripe transaction. Apple is the authority for an App Store transaction. RevenueCat helps Slate interpret and reconcile Apple's purchase state. Slate's database is the authority for what Slate will unlock right now.

That division is the one-ledger rule: every trusted money path writes an independently identifiable grant into one app-owned entitlement ledger, and every access check reads that ledger. The app never asks the browser, “did checkout look successful?” It does not ask Stripe on every page load. It does not let an Apple refund erase a separate web purchase. It reduces several payment histories to one local authorization answer while preserving where each grant came from (adopted entitlement-ledger decision).

Slate makes the rule concrete. Its web checkout is Stripe. Its native purchase is Apple's in-app purchase system, observed through RevenueCat. Both bind to the same Supabase user UUID. Stripe, Apple, and manual grants converge in slate.entitlement_grants; access is the union of active lifetime grants (adopted entitlement-ledger decision).

Stripe, Apple, and manual grants merge into one Slate entitlement ledger MONEY → PROVIDER GRANT → ONE ACCESS ANSWER WEB / STRIPEIOS / APPLEMANUAL signed-in Slate web appStripe Checkoutverified Stripe webhookvalidate mode · paidclient_reference_idconfigured priceupsert source=stripe grant signed-in Slate iOS appApple IAP / StoreKitRevenueCat customer stateauthenticated + HMAC-verified webhookor signed-in syncre-fetch authoritative subscriberupsert / deactivate onlysource=apple approved server-sidemanual grantsource=granted slate.entitlement_grantskey: (source, external_id) any activelifetime grant? SLATE ACCESS refund deactivates only its provider row;never the merged answer directly
Stripe, Apple, and manual grants merge into one Slate entitlement ledger

Cards and in-app purchase are different rails

Stripe processes web payments. It can host checkout, collect card details, create payment objects, send events, handle refunds and disputes, and pay proceeds to the configured business. Stripe's US list price for a standard online domestic-card transaction was 2.9% plus $0.30 on July 15, 2026; international cards, currency conversion, and additional Stripe products can add separate costs (Stripe pricing, dated stack audit).

Apple in-app purchase is part of App Store distribution. Apple presents the purchase sheet, processes the transaction, applies App Store policy, and pays proceeds after its commission. Apple's published standard commission is 30% for digital goods and services, with 15% applying to qualifying subscriptions and qualifying programs (Apple Developer Program).

The App Store Small Business Program can reduce commission to 15% for an enrolled developer whose prior-calendar-year proceeds were no more than $1 million and whose current proceeds across associated developer accounts remain within the program threshold. Enrollment is required, associated accounts count, and crossing the threshold restores the standard rate for future sales (Apple Small Business Program).

Whether Slate may direct an iOS purchase to the web is not answered by comparing 2.9% with 15%. App Review rules govern in-app digital unlocks, reader-app treatment, external links, and storefront-specific exceptions; those rules can change and must be checked for the actual release (App Review Guidelines, dated stack audit). A lower transaction fee is irrelevant if the submitted flow violates the distribution agreement.

RevenueCat sits on top of the store rail. Its SDK and backend normalize purchase state, customer identity, entitlements, analytics, and webhook delivery so Slate does not implement every StoreKit receipt and state transition itself. Apple still processes the iOS sale and takes its commission. RevenueCat is a separate service: its public pricing was free through $2,500 in monthly tracked revenue and 1% of monthly tracked revenue above that threshold on July 15, 2026; RevenueCat defines tracked revenue before store commission (RevenueCat pricing, dated stack audit).

Those are public list rules, not the portfolio's verified effective rates. ABSENT — resolve by: record the live Stripe entity and negotiated rate, enabled paid products, international and refund mix; RevenueCat plan and monthly tracked revenue; Apple Small Business enrollment, associated accounts, proceeds, and applied commission.

The browser is not a receipt

A checkout flow has a tempting ending: the provider redirects back to a success page. That page is useful feedback, but it is not durable proof of payment. A tab can close before the redirect. A redirect URL can be visited again. The asynchronous payment can change state. The browser is controlled by the customer and cannot safely award its own access.

Stripe instructs integrations to use webhooks for asynchronous events and to verify the Stripe-Signature header against the exact raw request body. In a Next.js route, that means reading the bytes with request.text() before any request.json() call; parsing first changes the verification input (Stripe webhook documentation, Next.js raw-body footgun). In live mode, Stripe can retry deliveries for up to three days, and events can be duplicated or arrive out of order. A fulfillment handler must therefore be signature-verified, idempotent, and driven by the payment object's actual state rather than by navigation.

Slate's checkout binds the transaction to its own identity before leaving the app. The server creates a payment-mode Checkout Session and places the signed-in Supabase user UUID in client_reference_id. The webhook later requires payment mode, paid status, that user reference, and the configured price after reading the session's actual line items (adopted entitlement-ledger decision).

That list matters. A valid Stripe signature proves Stripe produced those bytes for the configured endpoint secret. It does not prove the event is the right type, belongs to Slate's intended product, represents a paid checkout, or maps to a real Slate account. Authentication answers “who sent this?” Product validation answers “does this event authorize the grant?”

When the predicate passes, Slate writes both its compatibility entitlement row and the source-specific grant in one database transaction. The grant's external identifier is the Stripe Checkout Session ID. A duplicate delivery hits the same (source, external_id) boundary instead of creating duplicate access (adopted entitlement-ledger decision).

Keep delivery identity separate from business identity. A production endpoint should claim Stripe's event.id so a retried delivery cannot run side effects twice, then upsert the business object—here, the Checkout Session grant—under its own stable key (idempotency report, webhook reliability discussion). The adopted Slate decision documents the grant key but not a Stripe event-claim store. ABSENT — resolve by: add or verify a durable Stripe event.id ledger, bind each ID to the received payload or object, and prove duplicate delivery cannot repeat any side effect.

The response should be quick because the ledger write is the fulfillment unit. Slow optional work can happen outside the webhook's critical acknowledgment path. If Stripe retries, the same external ID produces the same authorization result.

RevenueCat is a witness and a wake-up call

RevenueCat webhooks have the same basic problem—an internet request proposes a money-state change—but Slate does not treat the event body as the final Apple truth.

Slate's RevenueCat endpoint requires the expected authorization value, a timestamped HMAC over the exact body, a 256,000-byte request limit, optional app-ID matching, and the expected product and entitlement fields. It claims an idempotency key before reconciliation (adopted entitlement-ledger decision). RevenueCat's own documentation says webhook delivery has limited retries and that some events can be delayed, so an app still needs idempotency and reconciliation rather than assuming one perfectly ordered stream (RevenueCat webhook documentation).

After the event wakes the route, Slate calls RevenueCat's subscriber endpoint with an eight-second timeout and no cache. It accepts only Slate's exact lifetime product and checks whether expiry is absent or still in the future. Active state upserts an Apple grant. Inactive state deactivates only that Apple external ID and records revocation time (adopted entitlement-ledger decision).

That second fetch separates notification from authority. The webhook says, “this account's store state may have changed.” The subscriber response says, “this is RevenueCat's current view.” The ledger write says, “this is the evidence Slate will use for authorization.”

The native client does not rely only on webhooks. After refresh, purchase, or restore, it calls an authenticated sync route. The same signed-in Supabase user UUID configures RevenueCat's app-user identity, so web and native grants converge on the same internal account rather than on an anonymous store identity (adopted entitlement-ledger decision).

RevenueCat's sandbox is not production truth. Renewal timing, metadata, event behavior, anonymous aliases, and restore flows can differ, and RevenueCat recommends testing production configuration and replacing test keys before launch (RevenueCat sandbox guidance). Passing a StoreKit configuration test proves a development path; it does not prove the live App Store account mapping.

The ledger preserves disagreement

A single paid = true column looks simpler until two systems can grant and revoke access. If the Apple refund handler sets it to false, it can erase a valid Stripe purchase. If a Stripe event overwrites the row, it can destroy the Apple transaction identifier needed for support. If a manual grant shares the same slot, an automated reconciliation can remove an intentional exception.

Slate's current table stores one row per provider evidence item. The key is (source, external_id), where source is stripe, apple, or granted. Each row carries user_id, entitlement kind, active state, timestamps, and revocation state. The database can receive the same provider event repeatedly without confusing it with another authority (adopted entitlement-ledger decision).

The authorization read is deliberately boring: does this user_id have any active lifetime grant? The application does not care which checkout UI produced that grant while deciding whether to unlock Slate. It cares about source only while reconciling, auditing, refunding, or supporting the purchase.

This is a union, not synchronization between payment companies. Slate does not copy Stripe purchases into RevenueCat or RevenueCat purchases into Stripe. Both providers independently converge in the app-owned database, alongside manual grants (adopted entitlement-ledger decision). That correction prevents RevenueCat from accidentally becoming the owner of web commerce state it never processed.

The same structure handles disagreement safely. RevenueCat can mark the Apple row inactive while the Stripe row remains active. A duplicate Stripe event can update its existing row. A manual grant remains independent. Support can see why access exists instead of reverse-engineering one mutable Boolean.

Idempotency is not “ignore duplicate IDs”

Payment systems retry because networks fail. An idempotent handler makes repeated delivery of the same logical event produce the same durable result. A unique (source, external_id) key is the database floor. A scoped event store can also bind an event ID to a request hash so the same ID with different bytes is rejected rather than silently treated as the old request (adopted entitlement-ledger decision).

Transactions matter too. If a compatibility row and a ledger grant must both be written during migration, either both should commit or neither should. Otherwise a retry can encounter a half-finished state and authorization can depend on which read path one deployment still uses. Slate migrated additively: it kept the legacy table, created the grant ledger, backfilled existing entries, and switched the authorization read without dropping history (adopted entitlement-ledger decision).

Idempotency does not solve every ordering problem. At medium confidence, practitioners report receiving an updated event before the corresponding created event; handlers should upsert the provider object and use event.created or a fresh provider read to reject stale transitions rather than assuming delivery order (ordering report, second ordering report). An old “active” notification arriving after a refund can reactivate stale state if the handler trusts event fields. Slate's RevenueCat path avoids that trap by re-fetching current subscriber state. Stripe's equivalent lifecycle still needs explicit refund and dispute policy.

Metadata also stays on the object where you put it. At medium confidence, a recurring Checkout integration can silently fail when a handler expects Session metadata to appear on the Subscription; set and read metadata on the exact object the later event carries (metadata-placement report). Slate's current one-time path uses client_reference_id, but any future subscription path must test its real event payload instead of transferring that assumption.

The missing Stripe revocation path

The adopted decision is candid about an important gap: Slate's current Stripe route grants on checkout.session.completed, but the named implementation contains no Stripe refund or dispute revocation branch (adopted entitlement-ledger decision).

That does not make the ledger wrong. It means one authority can currently add evidence without the documented inverse operation. ABSENT — resolve by: define which Stripe refund, chargeback, and dispute states deactivate a lifetime grant; choose the authoritative Stripe object and external ID; implement verified idempotent handlers or periodic reconciliation; and test that Stripe revocation never deactivates Apple or manual grants.

The test should traverse both directions. A paid, correctly priced checkout creates one active Stripe grant. Duplicate delivery does not create a second. A wrong price, unpaid session, missing user reference, or altered signature creates none. A defined refund deactivates only that Stripe grant. If an Apple grant remains active, Slate remains unlocked.

For Apple, test purchase, restore, account change, inactive state, wrong product, stale signature, duplicate event, provider outage, and the authenticated sync path. Then verify the ledger row and the actual access read. A green webhook response is not the product outcome.

The one-ledger rule gives you a stable center while providers, storefront rules, retries, and purchase states move around it. Money enters through separate rails. Access leaves through one query you own.

On your stack
  • Slate's Stripe, Apple/RevenueCat, and manual paths write independent source grants; Stripe is not synchronized into RevenueCat, and access reads the union of active lifetime grants (entitlement-ledger decision).
  • Stripe Checkout and RevenueCat both bind to the same signed-in Supabase user UUID before their evidence reaches slate.entitlement_grants (entitlement-ledger decision).
  • Verify Stripe against request.text() before JSON parsing; claim event.id; tolerate out-of-order delivery; and read metadata from the exact object where it was written before separately validating the grant (Stripe webhooks, verified practitioner audit).
  • Apple's published commission and Small Business rules are separate from RevenueCat's fee; verify the portfolio's live enrollment and effective rates before modeling Slate's margin (Apple Small Business Program, stack audit).
  • Close and test the documented Stripe refund/dispute revocation gap without allowing one provider to erase another provider's valid grant (entitlement-ledger decision).

CHAPTER 10 · THE STACK

Email that arrives: Resend, SPF, DKIM, DMARC

Why can Resend say “sent” while the message never appears in the inbox—and what do three DNS acronyms have to do with whether HMD looks legitimate?

Because sending is not delivery, and delivery is not placement.

Your application can hand a message to Resend. Resend can hand it to the recipient's mail server. That server can accept it and later place it in spam, quarantine it, or suppress it from view. At each handoff, a different system checks identity, policy, reputation, content, and recipient state (Resend domain verification, SMTP).

SPF, DKIM, and DMARC do not make a message interesting or wanted. They make specific claims verifiable. SPF says which infrastructure may send for an envelope domain. DKIM says a domain took responsibility for a cryptographic signature over selected message content. DMARC connects one or both of those results to the domain the reader sees in the From address and publishes handling policy.

The verified practitioner audit repeatedly finds missing or conflicting domain authentication behind “Resend says sent, but it went to spam” reports (Resend deliverability thread, builder deliverability thread). The API call is the easy handoff. DNS authentication, sender inventory, reputation, complaints, bounces, volume ramp, and placement tests are the continuing operation.

HMD outbound email authentication and delivery trace, with Slate inbound routing separate HMD OUTBOUND DELIVERY TRACE SPF PROOFchecks SMTP envelope / return-pathdomain against authorized sendersin public DNS DKIM PROOFreads d= + selector s= from signatureretrieves public key from DNS DMARC PROOFreads visible From domainSPF or DKIM passed + aligned?apply p= policy + reporting addresscurrent HMD policy: ABSENT MESSAGE HANDOFFS HMD applicationor ConvertKitbroadcast systemsending serverpublic DNSrecipient mailgatewayspam / reputationfiltersinbox / spamfolder HMD exact ConvertKit record names / targets: ABSENTresolve by: export live DNS and ConvertKit domain records SEPARATE INBOUND LINE · RECEIVING PATH, NOT OUTBOUND AUTHENTICATION Cloudflare Email RoutingSlate Worker
HMD outbound email authentication and delivery trace, with Slate inbound routing separate

Email is a federation, not one app

Email is the oldest federated system in this stack. SMTP's standardized lineage predates the Web: RFC 821 specified SMTP in 1982, and today's base SMTP specification is RFC 5321 (RFC 821, RFC 5321). No single company owns the whole route. Independent domain owners publish DNS, sending providers operate outbound servers, receiving providers make local acceptance and placement decisions, and users read mail in separate clients.

The address itself exposes the federation. The domain after @ tells a sender which administrative domain owns the destination. DNS MX records identify that domain's receiving mail servers. SMTP then transfers the message between systems. A successful SMTP response means a server accepted responsibility at that handoff; it does not force a particular folder or user-visible result (RFC 5321).

This is why “email is down” is too large a diagnosis. DNS can be wrong. The sending provider can suppress the recipient. The receiving server can reject during SMTP. Authentication can fail. A mailbox rule can move the accepted message. A user can unsubscribe from a broadcast while still receiving a password reset from another stream.

The message has more than one sender identity

An email reader shows a friendly From address. SMTP also carries an envelope sender, commonly surfaced as the return path, for delivery status and bounce handling. DKIM introduces a signing domain. Those domains can match, be subdomains, or belong to different providers.

That flexibility makes outsourced sending possible. HMD can show a healthymandaily.com From identity while ConvertKit operates sending infrastructure. Resend can send application mail for a verified product domain. It also creates room for spoofing: without alignment policy, a message can display one domain while passing an authentication check for another (live inventory, DMARC).

Read the proofs as three different questions rather than three green checkmarks.

SPF: who may send for the envelope domain?

SPF, Sender Policy Framework, is a DNS-published authorization list. The receiving mail server takes the connecting server's IP address and the SMTP identity being checked—normally the MAIL FROM domain, with HELO as another defined identity—and evaluates that domain's SPF policy. The result can pass, fail, soft-fail, or return other defined states (RFC 7208).

SPF does not directly prove the human-visible From address. A provider can use its own return-path domain, pass SPF for that domain, and display another domain in From. DMARC is the layer that asks whether the authenticated SPF domain aligns with the visible From domain.

SPF records are published as DNS TXT data even when a provider supplies CNAME-based delegation around the return-path setup. One domain name must not publish multiple SPF records; the policy must be combined deliberately. Cloudflare's own Email Routing troubleshooting warns that multiple SPF records cause problems, and Resend lists missing, duplicate, or conflicting DNS records among common verification failures (Cloudflare Email troubleshooting, Resend domain verification).

SPF also has a DNS lookup budget defined by the standard. Provider includes and redirects consume that budget, so blindly stacking services can produce a permanent evaluation error even while every vendor's fragment looks plausible (RFC 7208). The fix is an inventory of the actual sending paths, not another TXT record.

DKIM: did a domain sign this content?

DKIM, DomainKeys Identified Mail, adds a cryptographic signature header to the message. The header names a signing domain with d= and a selector with s=. The receiver combines the selector and domain to find a public key in DNS, then verifies the signature over the headers and body portions declared by the signer. The private signing key stays with the sending system (RFC 6376).

The selector lets a domain rotate keys or let separate senders use separate keys without replacing one global record. A CNAME can delegate the selector's DNS answer to a provider, which is why HMD's live zone can carry ConvertKit DKIM CNAMEs while Cloudflare remains authoritative for the parent zone (live inventory).

A DKIM pass proves that the signed portions still match and that the signer possessed the private key associated with the DNS-published domain. It does not prove the message is wanted, truthful, or safe. A legitimate newsletter can be spammy; an attacker can authenticate a domain the attacker owns.

Mail systems can modify messages in transit. Mailing-list footers, subject tags, and body rewrites can break a signature depending on what was signed and the chosen canonicalization. That is one reason receivers consider SPF, DKIM, DMARC, reputation, and local rules together rather than treating one result as universal truth (DKIM).

DMARC: does an authenticated identity align with From?

DMARC begins with the domain in the visible RFC 5322 From header—the identity a reader is meant to see. The receiver checks whether SPF passed with an aligned authenticated domain or DKIM passed with an aligned signing domain. One aligned passing mechanism is enough for DMARC to pass. Alignment can be relaxed, allowing an organizational-domain relationship, or strict, requiring an exact domain match (RFC 7489).

The domain publishes a DMARC TXT record under _dmarc. Its p= tag requests treatment for messages that fail alignment: none, quarantine, or reject. Reporting tags can direct aggregate reports to an address prepared to receive and process them. The receiver ultimately applies its own local policy, so DMARC publishes a request and reporting framework rather than a command that controls every inbox (RFC 7489).

Starting with p=none can expose alignment data without requesting quarantine or rejection. Moving to enforcement before every legitimate sender is aligned can block real product mail. Remaining indefinitely at monitoring leaves less protection against direct From-domain spoofing. The right progression depends on an inventory of all real senders (DMARC).

DMARC is not a deliverability purchase. Resend's deliverability guidance describes it as policy for messages that do not pass SPF or DKIM validation, and its domain setup makes DMARC optional after SPF and DKIM verification (Resend domains, Resend deliverability insights). Inbox providers still use complaint history, bounces, engagement, content, and reputation.

Transactional and broadcast mail have different promises

Transactional mail follows a product event or account action: authentication messages, purchase receipts, or notifications the product needs to operate. Broadcast mail is sent to an audience as a campaign, as HMD does through ConvertKit. The same domain can support both, but they have different consent, unsubscribe, volume, cadence, and reputation risks (live inventory, Resend Broadcasts).

The boundary matters more than the template. A passwordless login link is time-sensitive and tied to one requested auth ceremony. An HMD newsletter issue is editorial content sent to a subscriber list. Treating both as one undifferentiated stream lets a broadcast complaint or list-quality problem threaten critical account mail.

Separate providers or subdomains can isolate some reputation and operations, but they also add DNS, monitoring, and policy work. Whatever the shape, the visible From identity, SPF return path, DKIM signing domain, and DMARC alignment need to be recorded per stream. Provider acceptance and a dashboard “delivered” label are still not measured inbox placement (Resend domain verification).

Inbound forwarding has its own placement trap. Cloudflare Email Routing may accept a message and forward it successfully while Gmail or Microsoft places the forwarded copy in spam; repeated 2025 reports make destination inbox testing necessary for founder aliases and recovery paths (Gmail forwarding report, Microsoft forwarding report). “Route active” proves routing configuration, not that a person will see the mail.

What Resend does—and does not do

Resend is the audited transactional and developer-driven sending option in this stack. An application calls its API; Resend handles SMTP delivery, domain verification, logs, bounces, complaints, suppressions, and webhooks. It sends outbound mail. Cloudflare Email Routing receives and forwards inbound mail, so the two services are not substitutes (stack audit).

The July 15 audit recorded public Resend list prices without claiming a live bill. Free was $0 with 3,000 emails per month, a separate 100-per-day limit, one sending domain, one webhook endpoint, and 30-day retention. Pro was $20 per month for 50,000 emails and ten domains, with listed additional volume at $0.90 per thousand. Scale was $90 per month for 100,000 emails. Every recipient counts as an email, and marketing contacts are a separate pricing dimension (Resend pricing explainer, stack audit).

Those limits explain one failure that code cannot fix: the monthly allowance does not erase the Free plan's daily cap. A large send can be blocked even when the month total remains below 3,000. This is a dated planning fact, not confirmation of the account's current plan or volume (Resend pricing explainer).

Resend also maintains suppressions after qualifying bounces or complaints. A call can be accepted by the API while the intended recipient remains suppressed, so the useful evidence is the email event trail and suppression state, not only the API response (Resend suppressions). Webhook monitoring turns delivery changes into application-visible events; without it, the provider dashboard becomes the only witness.

At medium confidence, practitioner guidance says provider choice is secondary to domain reputation, volume ramp, and content (deliverability-operations view). That does not make Resend interchangeable with every API. It means a provider migration cannot repair a duplicate SPF policy, a cold domain blasted at campaign volume, a damaged list, or ignored complaint signals.

The audit did not verify the live Resend account. ABSENT — resolve by: export the active plan, sending domains, SPF/DKIM/DMARC status, daily and monthly volume, peak send, suppressions, bounce and complaint rates, webhook health, retention, marketing-contact count, unsubscribe handling, and failover ownership; compare every DNS value with the authoritative Cloudflare zone. (stack audit)

HMD is the real broadcast example

The July 15 Cloudflare inventory found ConvertKit DKIM/SPF CNAMEs on healthymandaily.com, identifying HMD's newsletter sending setup. Its website records are separately DNS only to Vercel. The newsletter provider therefore participates in HMD's mail identity without sitting in front of the HMD web application (live inventory).

That finding is real but incomplete. The inventory did not preserve the exact hostnames and targets in the book ledger, did not name the visible From or return-path domains, and did not verify DMARC alignment or current provider status. ABSENT — resolve by: export the HMD DNS records and one redacted delivered-message header; map the SPF-checked domain, DKIM d=/s=, visible From domain, DMARC result and policy, ConvertKit verification state, and newsletter unsubscribe path. (live inventory)

That header is the closest thing to a delivery receipt for the authentication chain. It shows what the receiving system actually evaluated, which can differ from what the DNS dashboard appears intended to configure.

Debug the last confirmed handoff

If the API call fails, inspect application credentials, request validation, provider status, and quota. If Resend accepts but no SMTP delivery event appears, inspect suppression and provider processing. If the recipient server rejects, read its status code and authentication results. If it accepts but the user cannot find the message, inspect placement, mailbox rules, and a redacted full header.

If SPF fails, identify the envelope domain and connecting sender before editing DNS. If DKIM fails, identify the selector, signing domain, public key record, and whether content changed. If DMARC fails, ask which passing mechanism was supposed to align with visible From. Do not “fix DMARC” by publishing another SPF record.

Email arrives when a chain of independent systems agrees to keep passing it. Authentication records make the chain more accountable. Monitoring tells you where the agreement stopped.

On your stack
  • Use Resend as an outbound transactional API only where it is actually configured; its audited public Free limits include both 3,000/month and 100/day, while the live plan, volume, domains, and deliverability remain unverified (Resend pricing, stack audit).
  • Read SPF as authorization for the SMTP envelope identity, DKIM as a domain signature over selected content, and DMARC as alignment and policy for the visible From domain (RFC 7208, RFC 6376, RFC 7489).
  • HMD's authoritative Cloudflare zone contains real ConvertKit DKIM/SPF CNAMEs for newsletter sending, but exact values, DMARC enforcement, and observed alignment need a record export plus delivered-header check (live inventory).
  • Keep broadcast HMD mail operationally distinct from time-sensitive product/Auth mail, including consent, unsubscribe, monitoring, and reputation decisions (live inventory, Resend Broadcasts).
  • Cloudflare Email Routing and slate-email-ingest are Slate's inbound path; they do not replace Resend or ConvertKit for authenticated outbound delivery (email-ingress recipe, stack audit).
  • Treat deliverability as ongoing operations—domain authentication, sender inventory, volume, reputation, suppression, placement, and forwarding tests—not as the successful return value of an email API (verified practitioner audit).

CHAPTER 11 · THE STACK

When it breaks: Sentry, logs, and what production means

Why does a bug become harder to understand the moment it happens on slatereader.com instead of your laptop?

Because production is a different running system, not a ceremonial label for the same code.

It has a particular deployment, environment variables, network path, domain, Auth configuration, database state, traffic, browser mix, and third-party dependencies. Your local working tree can contain a fix that production has never received. Production can fail for one user, one route, or one database row while every local test passes.

The cure is not more guessing. It is evidence that connects a real failure to a release and a layer. Sentry records application failures as structured events. Vercel logs show what its runtime handled. Supabase logs and database evidence show what reached Auth or Postgres. Chapter 1's request path tells you where those sources belong (stack audit).

Diagnose a Slate production request with evidence at every boundary SLATE REQUEST · DIAGNOSIS MAP browser /cacheCloudflareauthoritative DNSTLSVercel CDN /routingVercel FunctionpooledSLATE_DB_URLSupabasePostgres / Authresponse /render Cloudflare proxy: DNS ONLY DNS EVIDENCElookuprecord exportTLS EVIDENCEbrowser network tracecertificate / timingVERCEL EVIDENCEdeployment IDruntime logsSENTRY EVIDENCEenvironment · releasestack trace · breadcrumbsSUPABASE EVIDENCEAuth / database logsquery evidence BROWSER EVIDENCEconsole + source-mappedclient trace ROOT-CAUSE LOOP replicateresearchfixtestdocument root cause instrumentation + regression tests
Diagnose a Slate production request with evidence at every boundary

Production is the revision serving real traffic

The word “production” answers which environment currently serves the public product. For Slate, the public address is slatereader.com, one custom-domain project in the owner's 23-project Vercel team. Its checked records use Cloudflare authoritative DNS but are DNS only to Vercel, so a production page request does not pass through Cloudflare's proxy/CDN/WAF (live inventory).

The deployment is built from a Git revision, but the revision alone is not the entire environment. Vercel attaches configuration and runs the build and functions on its platform. Supabase supplies shared Auth and Postgres. A browser runs the returned client code with its own cache, extensions, device state, and network. The request path in Chapter 1, “What happens when you visit a website” is the diagnosis map.

This makes several green checks narrower than they sound. A commit means the source snapshot exists. A successful build means that snapshot compiled under the build environment. A successful deployment means Vercel created a runnable revision. A healthy root page does not prove the signed-in queue, email ingest, Google callback, or database write works.

Production also has real consequences. An error can block access, duplicate work, leak data, or silently discard a product action. “Works on my machine” proves only the exercised local path. The job is to identify what differs and capture it without copying sensitive user data into the investigation.

An error is a structured event

A console line is text at one moment. An error-monitoring event can carry the exception type and message, stack trace, timestamp, platform, environment, release, tags, request context, breadcrumbs, and a safely chosen user or trace identifier. Sentry defines an event payload as structured data representing an occurrence such as an error or transaction (Sentry event payloads).

Structure makes failures groupable. Repeated instances of the same underlying exception can become one issue with frequency, first-seen, and last-seen evidence instead of hundreds of unrelated log lines. Release metadata can show whether the issue appeared after a deployment. Environment tags prevent a local or preview failure from being mistaken for production (Sentry issue details).

Context must be deliberate. A route name, deployment, browser, and pseudonymous user reference can make a failure reproducible. Raw authorization headers, cookies, email bodies, private aliases, database URLs, and full request payloads can turn observability into a data leak. Sentry's Next.js guidance says sensitive-data controls, scrubbing, and SDK configuration determine what is transmitted; replay and user context deserve the same review (Sentry sensitive-data controls).

Sentry is not truth by volume. Browser extensions and injected scripts can produce errors the app does not own; the audit cites a long-running Sentry JavaScript issue documenting that noise (Sentry browser-extension issue, stack audit). Filters can reduce noise, but a broad deny rule can also hide a real product error. Evidence needs curation.

Stack traces tell you the path through code

When code throws, a stack trace records the active call chain: the current function and the callers that led there. On a server, file and function names may remain readable. Browser JavaScript is normally bundled, transformed, and minified for delivery, so the deployed trace can point at compressed generated code instead of the source the agent edited.

A source map connects generated positions back to original source files, lines, and names. Sentry needs the correct map for the exact deployed artifact and release; a map from another build can confidently point to the wrong source. Sentry's Next.js guidance covers generating and uploading the source maps for the deployed build so production errors resolve to the matching source (Sentry Next.js source-map guidance).

That is why “Sentry is installed” does not mean traces are useful. The production build must generate maps, upload them securely, associate them with a stable release/deployment identity, and avoid publishing private source maps as ordinary public assets unless that is an explicit decision. Then one production frame can lead back to the actual TypeScript line.

Source maps do not explain the cause by themselves. They restore names and locations. Breadcrumbs, inputs, downstream status, database state, and reproduction show why execution reached that line.

Logs are the running systems' notebooks

Logs record what a process or platform observed over time. Vercel runtime logs can show requests and output from functions; build logs belong to the earlier build stage. Vercel documents separate log surfaces for builds and for runtime activity (Vercel logs). A deployment that built successfully can still emit runtime errors on one dynamic route.

Supabase has its own evidence because Auth and Postgres are different systems. Its Logs Explorer exposes service-specific logs, and database logs can show errors that never become a browser stack trace (Supabase Logs Explorer). Slate also uses a pooled server-only database connection and schema-qualified queries, so a Vercel function error may be the caller-side symptom of a pool, permission, policy, migration, or query problem (shared-project recipe).

Cloudflare Worker logs belong only to the email-ingest path in this portfolio. Slate's web records are DNS only, but slate-email-ingest really does run at Cloudflare. Its logging contract intentionally records pseudonymous references, size, duration, and status without the private alias or message content (email-ingress recipe). A missing reading-queue item that arrived by email can cross Cloudflare Worker, Slate API, and Supabase; a broken page visit does not cross that Worker.

Good logs answer bounded questions: did the request enter, which release handled it, which safe correlation ID follows it, which downstream returned, and how long did the stage take? Logging entire objects because “we may need them” creates cost, noise, and privacy risk.

Start small before you need it

Instrumentation added after an incident cannot recreate the stack trace, breadcrumbs, release link, or sanitized context of the original failure. It can only watch for the next occurrence. That is why error capture, release tagging, source-map upload, and a minimal alert path belong in the production definition, not the postmortem wish list.

For a solo founder, the practitioner default is Sentry's free tier early, not a full APM program on day one. Error capture, releases, source maps, and one actionable alert close the largest evidence gap; an all-in logs/traces/replays platform is overkill until traffic or a diagnosed latency problem creates a question those extra signals can answer (solo-tooling discussion, monitoring setups).

The July 2026 audit describes Sentry as collecting errors, stack traces, releases, performance spans, logs, session replays, and user feedback. It notes that the feedback widget is only an intake surface; useful diagnosis still depends on source maps, releases, tags, privacy controls, alerts, and triage (stack audit).

The audit's public list-price snapshot, dated July 15, recorded Developer at $0 for one user and unlimited projects, with monthly allowances of 5,000 errors, 5 GB of logs plus a separate 5 GB of Application Metrics, 5 million spans, 50 replays, and a 30-day lookback. Team was $26 per month at the annual-billing rate with unlimited users, 50,000 errors, 5 GB of logs plus a separate 5 GB of Application Metrics, 5 million spans, 50 replays, and a 90-day lookback. Business was $80 per month at the annual-billing rate; paid plans could incur volume overages (Sentry pricing, stack audit). These are planning facts, not the owner's invoice.

Volume limits change instrumentation design. A tight client-side loop can consume the error allowance and obscure a new issue. Replays and performance traces create separate privacy and volume decisions. Sampling and spike protection preserve the observability system's usefulness; they should be chosen by event class rather than used to hide untriaged noise.

Noise is the early cost killer. Repeated warnings, extension errors, retry loops, and one noisy route can burn the quota before the incident you care about arrives (quota-burn report, MVP stack report). Filter known third-party noise, fix loops at the source, sample high-volume low-value events, and keep security/payment failures unsampled unless a deliberate threat model says otherwise.

The audit did not verify the live Sentry account or app coverage. ABSENT — resolve by: inventory the Sentry plan and every Slate/HMD/Hestya project; record DSN placement, production environment and release naming, source-map upload result, error/log/span/replay volumes, sampling and spike controls, alert owners, PII scrubbing and replay masking, retention, overage cap, and one exercised test error from production build to source-mapped issue. (stack audit)

Use the Slate route as the diagnosis map

Start at the outside and find the last confirmed hop. If DNS does not resolve, application logs are irrelevant. Check the Cloudflare authoritative record. If TLS fails before HTTP, check certificate and domain attachment. If Vercel returns a routing or build error, identify the production deployment. If static assets load but an authenticated route fails, inspect the function and its downstream calls.

At the data boundary, ask whether Supabase Auth recognized the session, whether the function used the intended identity, whether the pooled connection opened, whether the expected migration exists, and whether the query or RLS policy allowed the row. Slate's public client uses Supabase for Auth while its application data stays behind server connections, so an Auth success does not prove a database query succeeded (shared-project recipe).

At the browser boundary, inspect the network response before blaming rendering. Valid HTML can be followed by a client exception. A cached bundle can differ from the new server response. An extension error can appear beside the real application trace. The source-mapped production event and deployment ID decide which code actually ran.

If several apps sharing Supabase degrade together, widen the diagnosis to the project-level boundary. Separate schemas do not isolate compute, Auth, quotas, configuration, backups, or outages (stack audit). If only email capture fails, narrow the route to Email Routing, slate-email-ingest, signed API admission, alias resolution, and the atomic database insert (email-ingress recipe).

The error-fixing loop

Replicate. Write the smallest exact path that produces the failure: production URL or production-built artifact, identity state, action, observed result, timestamp, deployment, and safe correlation identifier. If it cannot be reproduced, preserve the original event and test competing explanations without claiming one is the cause.

Research. Read the evidence from the last confirmed hop outward: Sentry event, network response, Vercel logs, Supabase logs, relevant code and migration, then official documentation or current upstream issues. Research follows the observed error message and version; it does not begin with a generic rewrite.

Fix. Change the narrowest layer that violates the intended contract. A redirect allowlist problem is not repaired in a button label. A missing migration is not repaired by swallowing the database error. A source-map mismatch is an observability build problem, not the application exception it obscures.

Test. First execute the reproduction that failed. Then run the focused automated check and the relevant broader suite. For production-only configuration, exercise the production build or a faithful preview with the same integration boundary. Confirm the fix did not merely replace a visible error with silent loss.

Document root cause. Record the triggering condition, underlying system condition, why existing defenses missed it, exact fix, regression evidence, and any follow-up owner. “Agent changed three files” is a change log. “The production callback was absent from the redirect allowlist, and the new configuration test now asserts it” is a root-cause record.

The loop ends by improving detection or prevention. Add the regression test, alert, runbook note, safe log field, or deployment check that would shorten the next incident. Otherwise the same failure remains expensive even if today's symptom disappeared.

What good evidence sounds like

“Slate is broken” names a product. “The production deployment receives the authenticated request, then its server function fails before the slate.items query; Sentry maps the exception to this release and Vercel logs share the same trace ID” names a boundary.

That precision is the operational meaning of production. It is the real chain serving users, with enough evidence to prove which revision and system handled a request. Sentry, logs, and source maps do not prevent every failure. They turn failure from a story into an inspectable event.

On your stack
  • Diagnose Slate along the actual Chapter 1 request path: Cloudflare authoritative DNS, direct-to-Vercel HTTP, function, Supabase Auth/Postgres, and browser render (live inventory).
  • Treat Sentry setup as incomplete until a production build's test exception arrives with the correct environment, release, source-mapped frame, safe context, and actionable alert (Sentry Next.js source-map guidance, stack audit).
  • Start on Sentry's free tier with error evidence and quota controls; add full APM only when a real performance question justifies its cost, privacy surface, and operational load (verified practitioner audit).
  • Correlate Sentry events with Vercel runtime logs and the relevant Supabase service evidence; each source observes a different layer (Vercel logs, Supabase Logs Explorer).
  • Keep Slate email-ingest logging pseudonymous and bounded; that Cloudflare Worker is on the mail path, not the hero website request path (email-ingress recipe).
  • Use the full loop every time: replicate → research → fix → test → document root cause, then add the regression evidence or instrumentation that makes recurrence cheaper (stack audit).

CHAPTER 12 · THE STACK

Security for shippers

Why can a feature as ordinary as “save this URL” turn your server into a weapon?

Because a feature crosses a trust boundary the moment outside input gains inside authority.

Slate's URL saver can make network requests from Vercel. Its webhook routes can change stored data and paid access without a browser session. Its server environment can reach Postgres and payment providers. Its database can return one account's private rows. Each capability is necessary. Each becomes dangerous when the caller gets to steer it without a second check.

Security for a shipper is not a separate hardening week after the feature works. It is the admission rule at every boundary: which URL may the server fetch, which bytes may a webhook trust, which runtime may receive a secret, and which identity may touch a row.

The real Slate implementation gives you four reusable walls: a guarded outbound fetcher, exact-byte webhook verification, server-only secret handling, and database-enforced Row Level Security. None is sufficient alone. Together they keep one bug from becoming the whole system.

Four trust boundaries protect the Slate database from untrusted inputs FOUR TRUST BOUNDARIES IN SLATE UNTRUSTED INPUTSarticle URLinbound emailStripe eventRevenueCat eventbrowser request GATE 1OUTBOUND FETCH ADMISSIONprotocolpublic IP / DNSpinned socketredirecttime + byte limits GATE 2WEBHOOK ADMISSIONexact raw bytessignature / HMACtimestamp + key IDproduct / app checksidempotency GATE 3SECRET BOUNDARYbrowser-safe public configstays outsideSLATE_DB_URL + secretsinside Vercel / Workersecret stores GATE 4DATABASE AUTHORIZATIONauth.uid() = user_iddefault denialWARNINGprivileged roles canbypass RLS fetched content → SANITIZATION → storage slatePostgres schema BYPASS: raw fetch / parsed-before-verify /service key in browser / privileged query without authBLASTRADIUS
Four trust boundaries protect the Slate database from untrusted inputs

The 2025–26 incident class

The verified practitioner audit identifies a recurring vibe-code security triad: missing or incorrect RLS exposes rows; an unverified webhook accepts a fake payment or state change; and a service_role key or other server secret is bundled through a public environment variable (verified practitioner audit, RLS warning, webhook warning, public-secret report). Supabase's anon key is intentionally public when RLS is correct; the dangerous mistake is exposing privileged credentials or treating the public key as if it supplied authorization.

Missing or weak RLS is the defining failure in that triad. For the disputed CVE-2025-48757, a Lovable/Supabase authorization failure, MITRE's CNA rates the issue 9.3 Critical; NIST/NVD has not assigned its own score. The vendor disputes the record on the grounds that customers own protection of their application data. Practitioners describe automated scanning for the same class across agent-built apps (mass-scan discussion, Lovable warning). The CVE is the named incident, not the whole category: a different app with RLS toggled on can still have a permissive policy.

At medium confidence, the audit treats SSRF as the rising fourth class wherever a scanner, agent browser, MCP tool, import feature, or preview service fetches a user-controlled URL. Public solo-SaaS incident writeups are thinner than the RLS evidence, so treat the prevalence claim as an early warning while treating the technical boundary itself as established (agent SSRF warning, vibe-code security discussion).

SSRF: when the server fetches on command

Server-Side Request Forgery, or SSRF, begins with a useful product behavior: the server accepts a destination and fetches it. Slate needs that behavior to turn an article URL into a readable item. The danger is that the request comes from Slate's production network, not from the browser that supplied the URL.

A raw fetch(submittedUrl) can aim at loopback, private network ranges, link-local metadata services, credential-bearing URLs, or non-HTTP schemes. A public-looking hostname can resolve to a private address. A safe first page can redirect to an unsafe second destination. A hostname can change its DNS answer between validation and connection—the time-of-check/time-of-use form of DNS rebinding (Slate SSRF recipe).

That is why a URL fetcher is a weapon. It combines an attacker's steering with your server's network location, IP reputation, credentials, and bandwidth. The response can reveal internal data, trigger an internal action, or consume resources. OWASP classifies SSRF as a server-side request induced toward an unintended location and recommends allowlisting where possible plus network- and application-layer controls (OWASP SSRF Prevention Cheat Sheet). Slate cannot allowlist every article publisher, so it builds a strict public-internet fetch boundary instead.

Slate's adopted primitive performs the checks in order. It accepts only http: and https:, rejects embedded credentials, local names, fragments, and non-public IP literals. It classifies IPv4 and IPv6, including IPv4-mapped IPv6. It resolves the hostname and rejects the entire name if any DNS answer is not public (Slate SSRF recipe).

Then it closes the rebinding gap. The connection is pinned to the validated IP address while retaining the original hostname as the TLS server name. TLS still verifies the requested public host, but the socket cannot silently resolve a second time to a different private address (Slate SSRF recipe).

Redirects are manual. Slate allows at most five, resolves each Location, and passes every hop back through URL parsing and DNS validation. Automatic redirect following would validate only the first door and let the response choose the next one (Slate SSRF recipe).

Resource bounds are a separate defense. Slate uses one ten-second deadline, checks a declared Content-Length, counts actual streamed bytes even when the header is missing or false, cancels oversized bodies, and closes the request dispatcher. Its article default is 5 MiB; feed calls deliberately use a 20 MiB override. Those are product-specific limits recorded in the implementation, not universal safe numbers (Slate SSRF recipe).

Fetching safely does not make returned HTML safe. Slate keeps extraction downstream of the network boundary, restricts canonical URLs to same-origin HTTP(S) values without credentials, and sanitizes Readability output before storage. Network admission and content sanitization stop different attacks (Slate SSRF recipe).

The reusable rule is absolute: every destination derived from a person or remote discovery uses the same guarded primitive. One forgotten raw fetch in feed discovery reopens the boundary even if the article route is perfect. Tests should therefore cover call sites as well as address classification, redirects, mixed DNS answers, oversized streams, and timeouts.

Webhooks: authenticate the bytes before believing the story

A webhook is an unsigned-looking HTTP request with unusual authority. Stripe's webhook can grant paid access. Slate's email-ingress request can create an item in a private queue. RevenueCat's path can activate or revoke an Apple grant. None arrives through the normal signed-in browser flow.

The first rule is to verify the exact raw body. Stripe signs a payload and places evidence in the Stripe-Signature header; its documentation says signature verification requires the unmodified request body. Parsing JSON and serializing it again can change whitespace, key order, or encoding, so the new bytes no longer match what Stripe signed (Stripe webhook documentation).

Slate's email path makes that pattern visible without provider magic. The slate-email-ingest Cloudflare Worker serializes its bounded JSON once and computes an HMAC over timestamp + "." + bodyBytes. It sends the timestamp, signature, and a short non-secret key ID. The Next.js route reads at most 7 MiB, selects only the current or explicitly configured previous key, verifies the exact body through a fixed-length timing-safe comparison, enforces five minutes of age plus 30 seconds of future skew, and parses JSON only after verification (email-ingress recipe).

The timestamp narrows replay. The key ID supports controlled rotation without trying every secret. The byte cap stops the verifier from becoming an unbounded request sink. The constant-time comparison avoids exposing how much of a signature matched. Each control answers a different failure mode (email-ingress recipe).

Signature success is admission, not authorization. The email route still resolves the exact private envelope alias, applies entitlement and abuse caps, sanitizes content, and inserts the event and item atomically. Stripe's route still checks event type, payment mode, paid status, the Supabase user reference, and the configured price. RevenueCat's route checks app, product, entitlement, freshness, and then re-fetches current subscriber state (email-ingress recipe, entitlement-ledger decision).

Webhooks retry, so handlers also need idempotency. Stripe documents retries for up to three days in live mode and warns that duplicates and ordering must be handled by the endpoint (Stripe webhook documentation). Slate's ingestion path derives an event identity that includes the envelope recipient, writes the idempotency event with on conflict do nothing, and creates an item only for a newly claimed event. Its payment ledger keys grants by provider source plus external ID (email-ingress recipe, entitlement-ledger decision).

Verification should include altered bytes, wrong keys, stale and future timestamps, duplicate delivery, same event ID with different content, wrong product, and provider outage. A 200 response proves only that the route answered.

Secrets: give each runtime only its own authority

A secret is not made safe because it lives in a variable. The security question is which runtime receives the value.

Next.js can bundle variables prefixed for public use into browser JavaScript. Its environment-variable documentation says non-public variables remain server-side, while NEXT_PUBLIC_ values are inlined into the client bundle at build time (Next.js environment-variable guide). A database password, service-role key, Stripe secret, webhook signing key, or provider API key must never cross that boundary.

Slate has a concrete split. The public Supabase URL and anon key support Auth in the browser. Application data uses the separate SLATE_DB_URL through Next.js server code. Supabase warns that its service-role key bypasses RLS and must never be exposed on the frontend (shared-project recipe, Supabase API-key guidance).

Documentation should record environment names, owner, purpose, and rotation procedure, not values. A safe architecture note can say SLATE_DB_URL is server-only. It should not paste the URL, because a connection string can contain hostname, database, username, and password. The same rule applies to Stripe and RevenueCat webhook-secret variables and to the current/previous HMAC keys used by email ingress.

Secret stores reduce accidental repository exposure, but application behavior still matters. Do not log request authorization headers, signed bodies containing personal data, inbound aliases that act as capabilities, or full connection errors that echo URLs. Slate's Worker logs keyed pseudonyms and coarse metrics instead of the private email alias, subject, body, or Message-ID (email-ingress recipe).

Rotation is part of the design. Slate's email verifier supports an explicit current and previous key ID so the Worker can switch without an all-at-once outage. The old key should be removed after the overlap. A rotation plan that requires accepting unsigned legacy requests is not a rotation plan.

The live environment-name ownership, secret stores, and rotation dates are not fully inventoried in the supplied ledgers. ABSENT — resolve by: list environment-variable names only for Slate's Vercel project and slate-email-ingest Worker; classify each as browser-safe or server-only; record its owning service, last rotation, next rotation, and logs that could expose it; never export values into the book or repository.

RLS: the last wall, with a door for privileged roles

Application code should query only the signed-in account's rows. Row Level Security makes that intent a database rule. PostgreSQL applies RLS policies when a query runs; when RLS is enabled and no applicable policy exists, access is denied by default (PostgreSQL row-security documentation).

Slate puts user_id on user-owned rows and uses policies comparing it with auth.uid(). Its update policy checks both which old row may be targeted and whether the proposed row still belongs to the same identity. The service-only slate.ingest_events table has RLS enabled with no ordinary client policies (shared-project recipe).

That is the last wall when an app query is too broad. A missing where user_id = ... in the UI or Data API does not automatically expose every row if the querying role is subject to correct RLS. The rule lives beside the data instead of being repeated perfectly in every agent-written route.

But RLS is not magic. PostgreSQL superusers, table owners in normal circumstances, and roles with BYPASSRLS can bypass policies. Supabase's service role is intentionally privileged (PostgreSQL row-security documentation, Supabase RLS guide). A server route using privileged credentials must authenticate and authorize the request itself.

The adopted recipe does not identify the live role inside SLATE_DB_URL. ABSENT — resolve by: inspect that role's grants, ownership, and BYPASSRLS state; then run owner-allowed, cross-user-denied, unauthenticated-denied, and service-ledger-denied tests through every real browser and server access path. Do not settle the question by reading the migration alone.

RLS also needs coverage whenever a migration adds a table or operation. Test select, insert, update, and delete separately. Test that an allowed update cannot change user_id. Test a second authenticated identity, not only an anonymous request. “RLS enabled” is configuration; a red negative test is evidence.

The ShipFast caution: speed spends trust

ShipFast's 2024 security and paywall dispute is useful here precisely because it became public. The BuildFast research ledger records a fight over free access and bot abuse, followed by Marc Lou's public statements that the paywall was fixed and outside help was hired for security work (BuildFast X research ledger, Marc Lou's October 2024 post, later audit narrative). Later commentary continued to attach security reputation to the product (BuildFast X research ledger).

Do not turn that record into a forensic claim it cannot support. The ledger explicitly says the X posts could not be fetched anonymously during its July 2026 verification pass. It treats those citations as plausible but unverified, and the supplied material does not include a technical postmortem describing an exact vulnerability, affected records, or blast radius (BuildFast X research ledger).

The defensible lesson is about trust, not blame. At medium confidence, practitioners argue that vibe coding itself is not the root cause; shipping without a security checklist is (security-checklist view). A product sold as a fast foundation inherits responsibility for its authorization and payment boundaries. Once customers publicly question those boundaries, fixing code is only part of the recovery. The product must explain what was exposed, what changed, how the change was tested, and what prevents recurrence.

That lesson applies even more strongly to agent-generated foundations. Agents make it cheap to reproduce auth, checkout, webhooks, and URL fetchers. They do not make familiar-looking code safe. A starter compounds only when its security patterns are centralized, adopted, adversarially tested, and understandable enough that the owner can defend them.

For Slate, the standard is concrete: one outbound fetch primitive, raw-byte webhook verification, environment values confined to the right runtime, and negative RLS tests using the real roles. “Ship fast” remains useful only when those walls ship with the feature.

On your stack
  • Every Slate article and feed destination derived from outside input must pass through the adopted SSRF primitive; never retry a guarded failure with raw fetch (SSRF recipe).
  • slate-email-ingest, Stripe, and RevenueCat authenticate exact request bytes before parsing, then apply product checks and idempotency before changing data or access (email-ingress recipe, entitlement-ledger decision).
  • Keep SLATE_DB_URL and provider signing/API secrets server-only; document names and rotation procedures, never values (shared-project recipe, Next.js environment guide).
  • Treat Slate's owner, cross-user, unauthenticated, and service-only RLS checks as executable release gates, including the real SLATE_DB_URL role (shared-project recipe).
  • Audit the whole incident triad together: correct RLS policies, verified idempotent webhooks, and no privileged secret in a browser bundle; add guarded fetches wherever a feature follows outside URLs (verified practitioner audit).
  • Treat the ShipFast episode as a caution about public trust and missing verification evidence, not as a technical postmortem the current ledger does not contain (BuildFast X research ledger).

CHAPTER 13 · SHIPPING

Shipping to Apple

Why can you deploy a web app in minutes, yet still spend days getting the same product onto an iPhone?

The web lets you put new code at a URL and make that version the product. Apple asks you to produce a signed binary, connect it to a registered product identity, declare what it can do, upload it into App Store Connect, test it through Apple's distribution system, and submit it for review. Even after approval, you still choose when the first version becomes public.

That difference is not needless ceremony. An installed app can reach capabilities that a normal web page cannot, and Apple operates the store, signing chain, purchase system, and review gate through which public iPhone and iPad software travels. The Apple Developer Program is the membership layer that enables signed distribution, TestFlight, App Store submission, and production services (Apple Developer Program).

Slate makes the process concrete because its Apple round is in progress now. The native foundation exists. Its generated Xcode project, iOS and macOS apps, four platform-specific extensions, StoreKit test configuration, entitlements, privacy manifest, diagnostics, and tests were committed. Slate has not shipped to the App Store. Agreements, production purchase and diagnostics configuration, final assets, physical-device acceptance, archive validation, TestFlight approval, and manual release were still unchecked when the release shape was verified (Apple release recipe). Calling that “shipped” would erase the part of Apple shipping that only begins after the code exists.

Slate's agent and human release lanes join at a signed archive before App Store release SLATE RELEASE PIPELINE AGENT PLAN Swift source +resources + testsapple/project.ymlXcodeGengeneratedSlate.xcodeprojbuild / testfixesarchive candidate NOT RELEASED YETcurrent stage: archive candidate HUMAN CHECKLIST legal seller +agreementsimmutable IDs +capabilitiesApp Store Connectproduct / IAPprivacy answers +real screenshotsphysical iPhone / iPad/ Mac acceptance SIGNED ARCHIVE TestFlight review +smoke testApp Reviewmanualrelease TestFlight / App Review feedback → both lanes
Slate's agent and human release lanes join at a signed archive before App Store release

A binary needs an identity

A web deployment is identified mainly by its URL and the platform mapping behind that URL. An Apple build has several identities that must agree. There is the developer team signing it, the app's bundle identifier, each extension's bundle identifier, the App Group used for shared data, the keychain access group, the App Store Connect record, and each in-app purchase identifier. Code signing lets the operating system verify who signed the app and whether the signed contents changed afterward (Apple code-signing support).

Slate's main bundle ID is com.slatereader.Slate. Its shared App Group is group.com.slatereader.Slate; its lifetime in-app purchase is com.slatereader.lifetime. Those are not decorative strings. Entitlements, provisioning, StoreKit configuration, RevenueCat, native runtime configuration, and server reconciliation all depend on exact matches (Apple release recipe). A one-character disagreement can leave a build that compiles but cannot share credentials, open an associated link, or recognize a purchase.

Signing also explains why agents cannot finish the whole release by editing source files. Certificates, profiles, private keys, account roles, and agreements live at trust boundaries outside the repository. Slate's checklist explicitly keeps private keys, passwords, certificates, and provisioning profiles out of chat and git. An agent can prepare identifiers and explain the next screen. The account holder must control the credentials and approve the legal or financial action (Apple release recipe).

XcodeGen turns project state into reviewable text

An Xcode project contains targets, files, packages, build settings, signing choices, schemes, capabilities, and relationships between apps and extensions. If all of that lives only in the Xcode interface and a large generated project file, it is difficult to tell what an agent changed or to recreate the project cleanly.

XcodeGen moves the intended structure into a YAML or JSON specification and generates the Xcode project from it. It does not compile, sign, or submit the app; Xcode and Apple's toolchain still perform those jobs (XcodeGen repository, 2026 stack audit). The gain is visibility. Slate's apple/project.yml records deployment targets, Swift settings, exact package versions, source directories, resources, entitlements, embedded extensions, and schemes. The generated Slate.xcodeproj is committed beside it, so a change can be reviewed as both intent and result (Apple release recipe).

This creates a useful source-of-truth rule: lasting project changes go into the manifest. A manual edit made only inside the generated project can disappear the next time XcodeGen runs. The stack audit therefore recommends pinning the generator, matching local and automated versions, and validating after Xcode upgrades (2026 stack audit). Generation makes drift visible; it does not make signing or archive behavior simple.

Slate uses that contract to describe more than one app. SlateIOS embeds iOS Share and Safari extensions. SlateMac is a native macOS target and embeds macOS versions of those extensions. Both apps reuse shared Swift code while selecting platform resources and entitlements. Each extension receives its own bundle identity and only the capabilities it needs (Apple release recipe). You can now see why “the Slate app” is several signed programs traveling together.

Universal purchase is one commercial promise, not one binary

Slate models iOS and macOS inside one App Store Connect product record while building separate native targets. Apple calls the store relationship a universal purchase: supported platform versions can share one app record and in-app purchase identity, allowing a customer to access the purchase across those platforms when configured correctly (App Store Connect universal-purchase guidance).

For an indie app with paid access, wire RevenueCat from day one rather than treating raw StoreKit receipt and state handling as a later cleanup. That is strong 2026 practitioner advice because identity, restore, refund, entitlement, and webhook behavior shape the product model before launch (indie RevenueCat advice, second practitioner report). RevenueCat reduces that implementation burden; Apple still owns the transaction and Slate still owns the final authorization read.

The important word is “configured.” One product page does not magically reconcile every purchase. Slate's iOS and macOS schemes point at the same StoreKit test configuration. The non-consumable product uses the same com.slatereader.lifetime identifier in native configuration. The purchase layer uses the authenticated Supabase user ID as RevenueCat's app user and immediately asks Slate's server to reconcile the entitlement (Apple release recipe). StoreKit records the Apple transaction; RevenueCat interprets purchase state; Slate's server decides what the signed-in Slate account may use.

This is also why “Restore Purchases” is product behavior rather than store chrome. A person can reinstall, move between devices, or arrive on the other platform. The app has to recover the store transaction, attach it to the correct product identity, and reconcile access without granting one person's purchase to another account. Sandbox tests are necessary but are not production truth; RevenueCat warns that sandbox timing and behavior differ and recommends checking production configuration before launch (RevenueCat sandbox guidance, 2026 stack audit).

TestFlight is distribution before release

A local simulator proves only part of the product. A locally signed device build proves more, but it still does not prove that the uploaded archive, App Store configuration, receipt environment, associated domains, or extension packaging work through Apple's path.

TestFlight distributes prerelease builds through App Store Connect. Apple allows internal and external tester groups; the first build for external testing must pass TestFlight App Review. Testers can send feedback with screenshots and crash context from the installed build (TestFlight overview, TestFlight feedback). That makes TestFlight a release environment, not a ceremonial waiting room.

Slate's checklist requires real-device passes on iPhone, iPad, and Mac, including purchase, restore, refund or revocation behavior, share and Safari extensions, offline behavior, accessibility, split view, account deletion, and production smoke testing. It also requires real screenshots from the shipping build and a manual first release (Apple release recipe). None of those checks can be replaced by “the Swift tests passed.”

TestFlight also creates a controlled place for the product to be imperfect. A rejected external-test build or a tester report does not mean the whole release failed. It means the pipeline found something before public distribution. The fix may belong in Swift, XcodeGen, App Store metadata, a server endpoint, or an account setting. The value of the pipeline is that it tells you which boundary disagrees.

Review examines the whole product claim

App Review is not a code review. Apple evaluates the submitted binary and the experience around it against the current guidelines: safety, performance, business behavior, design, and legal requirements. Digital purchases, account access, privacy disclosures, and review credentials can matter as much as whether the interface launches (App Review Guidelines).

That is why Slate keeps a privacy manifest and still treats App Privacy answers as separate work. A manifest describes particular declarations inside the binary. The store label must reflect the actual shipping binary, SDK behavior, server data handling, retention, and privacy policy. Copying empty arrays from a foundation commit would not prove that the released app collects nothing (Apple release recipe).

Review is also repeatable. Approval of one binary does not permanently approve every later behavior or business model. Apple updates its rules, storefront-specific requirements can differ, and a new binary or metadata change can expose a new issue. The stack audit's instruction is simple: check the current guideline at release time, especially for digital-goods purchase paths and external links (2026 stack audit).

Run a review preflight from the uploaded build, not the simulator: confirm the paywall loads production IAP products; purchase and Restore Purchases both reconcile access; account deletion is reachable when accounts can be created; every used protected capability has an honest privacy purpose string; and product/subscription metadata is complete. Blank paywalls, unapproved products, missing restore behavior, account-deletion gaps, and privacy declarations repeatedly produce review loops (IAP review-loop thread, RevenueCat review thread, account-deletion rejection, privacy-string rejection).

Budget for more than one round. Practitioners report multi-rejection paths even when the eventual fix is small, and at medium confidence the same app can pass one review and fail a later one as review interpretation changes (eight-rejection report, moving-target report). Each response should name the tested build, exact reviewer path, and evidence instead of resubmitting unchanged and hoping for another reader.

Split the work by authority, not by difficulty

The cleanest Apple plan has two owners. The agent owns work that can be derived, reviewed, and tested from the repository: generating the project, aligning identifiers, writing native flows, adding tests, checking the associated-domain endpoint, preparing draft metadata, and turning compiler failures into patches. The human owns work that requires identity, money, legal representation, real hardware, private credentials, or subjective acceptance.

This is not “agent work first, human work later.” The lanes interleave. The human settles the seller and product identity before the agent wires signing. The agent produces a build before the human can capture truthful screenshots. The human configures production products before the agent can verify purchase reconciliation. A TestFlight result can send both of them backward.

Slate's checklist is the coordination artifact. It records what remains open instead of letting a generated project impersonate completion. At verification time, the live Apple membership status, Small Business Program enrollment, agreements, banking and tax setup, and current production catalog were not established in the BuildFast evidence. ABSENT — resolve by: account holder opens Apple Developer and App Store Connect, records membership renewal, legal controller, agreements/banking/tax status, Small Business enrollment, and the production product IDs without copying credentials into the repository. The same absence is called out in the dated audit (2026 stack audit).

The store changes the economics

The United States Apple Developer Program list fee is $99 per year. Apple's standard commission on digital goods and services is 30%; qualifying subscriptions and programs can receive 15%. The App Store Small Business Program offers a 15% commission to enrolled developers who meet its proceeds and associated-account rules, including the $1 million threshold described by Apple. These were the official terms recorded on July 15, 2026; actual enrollment and proceeds remain live account facts, not assumptions (Apple Developer Program, Small Business Program, 2026 stack audit).

Those costs buy more than payment processing. They buy access to Apple's signed distribution, TestFlight, App Store hosting, purchase rails, and customer trust—but they also accept Apple's review and commercial policy. RevenueCat can simplify entitlement handling, but it is a separate service and cannot decide whether a purchase flow complies with Apple rules (2026 stack audit).

For Slate, the next meaningful milestone is therefore not “native code exists.” It is a signed, correctly configured archive surviving the real pipeline: device, TestFlight, review, and manual release. Until that happens, the honest label is in progress.

On your stack
  • Slate has a committed XcodeGen foundation for separate native iOS and macOS apps plus four extensions, but it has not completed TestFlight/App Store release (Apple release recipe).
  • apple/project.yml is the reviewable project contract; XcodeGen generates the project, while Xcode and Apple still build, sign, archive, and distribute it (XcodeGen repository, Apple release recipe).
  • Slate's one product record and shared lifetime product identity support the universal-purchase goal; purchase and restore still need production reconciliation and real-device verification (Apple release recipe).
  • Keep RevenueCat in the first purchase architecture, then preflight production IAP loading, restore, account deletion, privacy strings, and metadata before expecting multi-round review (verified practitioner audit).
  • Keep account, legal, financial, credential, device-acceptance, screenshot, and release approval steps human-owned; keep repository generation, implementation, tests, and evidence preparation agent-owned (Apple release recipe).
  • Budget from the dated official terms—$99/year plus the applicable 15% or 30% commission—but verify the owner's actual membership and Small Business status in the live Apple accounts (2026 stack audit).

CHAPTER 14 · SHIPPING

Capture surfaces: extensions and PWAs

Why does Slate need a Chrome extension or a share target when it already has a website?

Because reading and saving happen in different moments. The best place to read is not always the place where you discover something worth reading. A capture surface closes that gap: it takes the page already in front of you, gathers the smallest useful payload, and hands it to the product that will own the item.

Slate's product playbook names the iPhone app as the hero surface. That is where the core loop—focused, offline, share-driven reading—should receive the most design attention. The Chrome extension maximizes capture from a desktop browser. The PWA share target accepts capture from operating-system share flows. The web app handles account, marketing, import/export, and a usable desktop reader. A Mac app earns a richer role only if demand supports it (Slate product playbook).

This is the spokes-versus-hero rule. One surface gets the love. The other surfaces feed it. If every companion must reproduce the whole reader, settings system, library, and purchase experience, capture stops being a small convenience and becomes several products that can drift.

Slate's Chrome extension exists in the Slate repository's extensions/chrome directory (verified 2026-07-16). The product playbook names the PWA share target as a Slate companion job (Slate product playbook). BuildFast's reusable recipes are less mature: “Chrome MV3 permissions and auth handoff” and “PWA share-target contract, retries, and offline behavior” are both still marked planned with none-yet evidence. The recipe index says a recipe should be written only from shipped code and deleted if nothing exercises it (extension/PWA recipe index). Existing product code is not yet a proven portfolio standard.

Narrow capture spokes feed the Slate iPhone hero through one reliable save pipeline CAPTURE SPOKES · ONE HERO SURFACE SLATE IPHONE APP — HEROread · retain · finish Chrome MV3 extensionURL / title capturePWA share targetOS-shared URL / textiOS Share Extensionnative share payload validate inputauthenticateor queueidempotentsave APIconfirmation / retry Slate webaccount / import / export+ desktop reader ANTI-PATTERN · FOUR PRODUCTS, ONE FOUNDERequal surface 1equal surface 2equal surface 3equal surface 4feature parity ↔ feature parity ↔ feature parity
Narrow capture spokes feed the Slate iPhone hero through one reliable save pipeline

An extension is a privileged guest in the browser

A Chrome extension is not just JavaScript that happens to appear near a webpage. It is an installed package with a manifest declaring its identity, entry points, and permissions. Manifest V3 is the current Chrome extension platform. It replaces a permanently running background page with an extension service worker, tightens remotely hosted code rules, and makes the manifest's permission boundary central to what the extension may do (Chrome, “Manifest V3”).

The pieces run in different contexts. A content script can read or change a page when the extension is allowed to run there. A popup or other extension page provides the small interface opened from the toolbar. A service worker responds to extension events and then can stop; Chrome explicitly warns that global variables are lost when the worker shuts down, so durable state belongs in storage rather than process memory (Chrome extension service workers).

That lifecycle matters to a capture button. The user can click Save after the background worker has been asleep. If the implementation assumes an in-memory access token, retry queue, or “current item” still exists, the click can fail without an obvious crash. The capture operation must reconstruct its state, read durable configuration, and be safe to resume.

Permissions are product scope written as security policy

Manifest V3 splits authority into categories. API permissions such as storage allow named extension capabilities. Host permissions allow access to matching sites. Content scripts specify where injected code may run. Optional permissions can be requested later, in response to a user action, instead of being demanded at install time (Chrome permissions documentation).

The distinction is visible to the person installing the extension. Some permissions produce warnings, and Chrome recommends using the narrowest permissions necessary and optional permissions where practical (Chrome permission warnings). A Slate capture extension should not ask to read every page merely because that is convenient to implement. If clicking the toolbar can capture the active tab, the design should start from that explicit action and the minimum tab/host access that supports it.

This is not security theater. A browser page can contain private documents, health information, unpublished work, or account tokens. An extension that can inspect all pages has a much wider failure radius than a bookmark form on slatereader.com. Every extra permission is a capability an extension bug or compromised dependency might exercise.

The manifest therefore tells you something about product discipline. “Save the current article to Slate” is narrow. “Observe every page and synchronize arbitrary content continuously” is a different product and should face a different review. Agents can make the larger permission set compile faster; they cannot make the trust request smaller after the fact.

Authentication is a handoff, not a copied secret

The extension needs to associate a saved URL with a Slate account. That does not mean packaging a server secret in the extension. Installed extension files are delivered to users and should be treated as public client code. Chrome provides an identity API and web authentication flow for supported OAuth-style handoffs; the final design still needs a Slate-specific session and token policy (Chrome Identity API).

The safe mental model is that the extension presents user-scoped evidence to a Slate server endpoint. The server validates that evidence, validates and normalizes the captured input, and writes the item under the authenticated user. The endpoint—not the popup—owns authorization. If a token expires, the extension should reauthenticate or preserve a clearly bounded retry, not fall back to an anonymous privileged path.

The handoff also needs an answer for sign-out and account switching. Browser extension storage can outlive a web tab. A person who signs out of Slate on the web may reasonably expect the capture extension to stop using the old account. ABSENT — resolve by: document and exercise Slate's extension auth source, token storage, expiry, sign-out, account-switching, and server-revocation behavior; then cite the product commit in the BuildFast recipe. The current recipe index intentionally does not claim this evidence yet (extension/PWA recipe index).

A PWA can receive an operating-system share

A Progressive Web App is still a web application, but an installed PWA can integrate with supported operating-system features. The Web Share Target API lets an installed PWA register itself as a destination in the system share interface. The web app manifest declares a share_target action, method, encoding type, and the parameter names that will carry shared title, text, URL, or files (MDN, share_target).

When someone shares a page to Slate, the operating system maps the shared data into that declared request and opens the PWA at the action URL. The receiving route must treat every field as untrusted input. It decides whether a URL is present, whether text contains a usable URL, whether the content type is accepted, and what the signed-in account may do. Registering the manifest entry does not implement the save.

The PWA route also lives under ordinary web constraints. The device may be offline. The user may not have an active Slate session. The operating system may launch the target cold. The share can be delivered twice after a retry. A companion surface feels reliable only when those states have explicit outcomes: saved, queued, needs sign-in, rejected, or retryable.

Offline support is especially easy to overclaim. A service worker can intercept requests and cache assets, but it does not make arbitrary writes durable by itself. Background Sync can defer work in supporting browsers, yet browser support and execution timing vary; the application still needs a persistent queue and idempotent server behavior (MDN, Background Sync API). Slate should promise only the offline behavior it has exercised on its supported platforms.

ABSENT — resolve by: run Slate's PWA share target through signed-in, signed-out, offline, duplicate-delivery, malformed payload, and retry cases on the actual supported devices; record browser/OS versions and the exercised commit in the recipe. Until then, “PWA share target” describes the real companion job, not a reusable reliability guarantee (extension/PWA recipe index).

Every spoke should share one save contract

The browser extension and PWA share target arrive through different platform doors, but they should converge quickly. Both produce some combination of URL, title, selected text, and source context. Both need authentication. Both can retry. Both need an answer from Slate's server.

A shared save contract reduces drift. First normalize the URL: accept only the protocols Slate intends to fetch, remove fragments when they do not identify distinct content, and preserve parameters only according to an adopted rule. Then make the write idempotent so a retry does not create a second unread copy. Finally return a small stable result the spoke can present: saved, already present, needs authentication, invalid, or temporarily unavailable.

That contract should not move article fetching into the extension merely to save a round trip. Slate's server is the place where URL safety, extraction behavior, rate limits, user ownership, and observability can be consistent. The extension's job is to capture. The share target's job is to receive. The hero product's backend owns the durable item.

The visual response should be equally narrow. A successful extension does not need to become Slate in a popup. It needs to say that the article was saved, identify the destination account when useful, and make failure recoverable. The PWA target does not need to open the full library before acknowledging a queued save. Short interaction paths are the reason these surfaces exist.

One hero prevents parity debt

The hero-surface decision is a resource allocation rule. Slate's iPhone app owns activation, weekly retained use, task completion, crash/error budget, accessibility, and launch polish. Those are the scorecard categories in the product playbook (Slate product playbook). A capture spoke can have a smaller scorecard: successful captures, recoverable failures, permission trust, and time from share to confirmation.

Without that hierarchy, every platform request sounds reasonable. Add folders to the extension. Add the full reader to the PWA. Add every web setting to the native app. Soon a change to one library field requires four interfaces, four test passes, four authentication edge cases, and several release channels. None is obviously allowed to lag because none was named a companion.

HMD and Hestya show why the hero is product-specific. HMD's hero is its public website because discovery, crawlable articles, newsletter conversion, archive, and trust meet there. Hestya's hero is the Mac desktop app because invocation, keyboard workflow, local context, and desktop integration define the product. Slate's is mobile because the reading loop lives there (web research, surface strategy). “Native is always best” and “the web should do everything” are both worse rules than asking where the product's repeated value occurs.

For Slate, capture surfaces should disappear into that repeated value. Their success is not time spent inside the extension or PWA. It is that an article found elsewhere reaches the reading queue with less friction and without asking the user to understand the plumbing.

On your stack
  • Slate's iPhone app is the hero; the Chrome extension and PWA share target are capture spokes, while web owns account, marketing, import/export, and desktop reading (Slate product playbook).
  • The Slate Chrome extension exists in the Slate repository's extensions/chrome directory (verified 2026-07-16), but BuildFast's reusable MV3/auth recipe is still planned and none-yet; do not present product code as a proven cross-portfolio standard (extension/PWA recipe index).
  • Keep MV3 permissions as narrow as the capture job and persist state because extension service workers can stop between events (Chrome permissions documentation, service-worker lifecycle).
  • Make both capture spokes converge on one authenticated, validated, idempotent Slate save endpoint; exercise retries and offline behavior before promoting the pattern to a recipe (extension/PWA recipe index).
  • Measure companion surfaces by reliable capture, not feature parity; reserve the portfolio's full launch-polish scorecard for each product's chosen hero (Slate product playbook).

CHAPTER 15 · SHIPPING

Launching: waitlists, cohorts, and lead magnets

When is a product ready to leave your laptop without pretending it is ready for everyone?

Not at one dramatic moment. A useful launch is a sequence of controlled promises: who may enter, which capabilities they may use, how quickly you can reverse a bad release, where feedback arrives, and what public page explains the product to people and search engines.

The opposite is the big bang. You open signup, publish every feature, announce in every channel, and learn about access bugs, confusing language, payment problems, and support load at once. That creates noise, not evidence. For a solo founder, controlled beta is not timid marketing. It is a way to keep diagnosis possible while real people begin to use the product.

The draft BuildFast guide proposes an intentionally small path: a waitlist, one server-owned cohort value, four release states, named TestFlight groups for Apple, feedback inside the active surface, and a weekly triage. It derives that proposal from the portfolio's dated web research rather than presenting an exercised standard (controlled-beta path, web research).

A controlled launch moves people and capabilities separately, then triages feedback weekly CONTROLLED LAUNCH FUNNEL PEOPLE TRACK public product page/ lead magnetsmall waitlistreviewed invitebeta cohortactivationcore actionweekly-retainedusepublic cohort ACCESS IS NOT PAYMENT ENTITLEMENT CAPABILITY TRACK offinternalbetaall rollback from every state → off WEEKLY FEEDBACK TRIAGE TestFlightSentry feedbacksupport inbox ONE WEEKLYTRIAGE QUEUE bugconfusingrequestnot for this product
A controlled launch moves people and capabilities separately, then triages feedback weekly

A waitlist is an intake valve

A waitlist should collect only what changes an invite decision. The draft guide proposes email, product, desired use, and platform, with approvals reviewed in a weekly batch and the invitation action kept on the server (controlled-beta path). That is enough to know which product the person wants, what they expect from it, and which surface they can test.

The waitlist is not proof of demand by itself. It records interest before use. Its operational value is pacing. You can admit a group small enough to observe, fix the first serious problems, and then admit the next group without rewriting account access every time.

Supabase supports server-side administrative user invitations, and its documentation warns that the service_role key must never be exposed in a browser. A Before User Created hook can also reject signup that does not satisfy an adopted policy (Supabase user management, Supabase auth hooks). The important architectural point is not which of those mechanisms Slate, HMD, or Hestya chooses. It is that the browser does not get authority to approve itself.

Avoid turning the first form into customer research, onboarding, and a CRM at once. Every extra field raises friction and creates data you now need to protect and interpret. The missing knowledge belongs in a later conversation with an active or churned tester, when behavior has given you a concrete question.

One cohort value replaces scattered exceptions

The dangerous version of a private beta is a list of email addresses copied into page components, API routes, and feature checks. It feels fast until an address changes, a person should be removed, or one route forgets the list. Then access depends on which code path the person reached.

The draft guide proposes one durable, server-owned membership value such as owner, internal, beta, or public (controlled-beta path). That record would answer who the person is in the release process, with server routes enforcing it. The interface may hide unavailable controls for clarity, but hiding a button is not authorization.

This value is deliberately separate from payment entitlement. “Invited to beta” and “has a verified Slate lifetime purchase” answer different questions. A tester may be allowed into an unfinished feature without owning the final paid entitlement. A paying customer should not lose purchased access because a beta flag changed. BuildFast's research recommends keeping cohort and entitlement truth durable in Supabase and never using a feature-flag service as billing authorization (web research).

ABSENT — resolve by: name the person or role allowed to change cohort membership and record the decision beside the product's access model. This is still an open pre-beta item in the BuildFast path (controlled-beta path). An agent can build an admin action; it should not invent who is accountable for using it.

Four states make release reversible

Every risky capability should have four understandable release states:

The value is not the names. It is the explicit promotion path and the ability to retreat. A new Slate capture flow can begin with the owner, move to the beta cohort, and return to off if server errors or duplicate saves appear. “Deployed” no longer has to mean “visible to everyone.”

Vercel Flags is generally available and can target segments, support gradual rollout, and manage flag state. Durable cohort and entitlement truth still belongs in the database because those records authorize release access and paid capabilities; use remote flags for reversible rollout, not as the purchase ledger (Vercel Flags documentation).

The four states also improve agent plans. Instead of “ship the new flow,” the plan can say: implement the server gate, default it to off, verify the owner path under internal, promote it to beta, inspect feedback and errors, then choose all. Each verb has observable evidence and a rollback.

Apple beta is a real release channel

For Slate's native round, the beta gate includes Apple. TestFlight supports internal testers and named external groups. Apple documents limits of up to 100 internal testers and 10,000 external testers, and the first build used for external testing must pass TestFlight App Review (TestFlight overview, external tester guidance). These are platform ceilings, not target cohort sizes.

BuildFast's path begins with email invitations to named external groups. A capped public link comes later only if manual invitations become the bottleneck (controlled-beta path). That preserves a relationship between the person admitted and the feedback you receive. It also makes the beta closable without taking the production product down.

ABSENT — resolve by: choose and test the maximum TestFlight public-link seats and an emergency closure procedure for Slate's actual beta. The current guide intentionally leaves the live number to the product owner (controlled-beta path). Apple's maximum is not automatically the right exposure.

One feedback loop beats five communities

Feedback is useful only when it reaches a decision. During beta, give each active surface one obvious front door and feed all reports into one founder-owned triage queue. TestFlight already captures comments, screenshots, crash details, and tester metrics for Apple builds (TestFlight feedback). Sentry's web feedback can attach a report to technical context. One support inbox catches account or product conversations that fit neither tool (Sentry feedback widget, controlled-beta path).

This is one loop even though the collection control matches the surface. The loop is the weekly review, the shared categories, and the resulting product decision. Do not add a Discord, Slack, public roadmap, separate idea board, and direct-message habit before message volume proves that one of them solves a real routing problem.

Once per week, sort each report into bug, confusing, request, or not for this product (controlled-beta path). The categories prevent every message from turning into a feature. A bug contradicts intended behavior. “Confusing” may be a label, sequence, expectation, or onboarding problem even when code works. A request expands or changes the product. “Not for this product” protects the hero surface and thesis from reasonable demands that lead elsewhere.

Interview active or churned testers when usage does not explain a report. Do not invent the reason from an analytics event. The point of the conversation is to connect observed behavior to the person's actual job, not to collect compliments.

ABSENT — resolve by: write the product's feedback response expectation and diagnostics privacy note before inviting testers. Sentry and TestFlight can carry screenshots, device details, crash context, and user-entered text; testers should know what is collected and when to expect a response (controlled-beta path).

A lead magnet starts the right relationship

A lead magnet is a useful public artifact exchanged for attention or permission to continue the conversation. For BuildFast, this book is the artifact. It teaches the system behind the products before asking a reader to buy a product or a template. HMD uses the same underlying shape in its own category: the website is the hero because public, crawlable articles support discovery and trust while email delivers the returning habit (web research, surface strategy).

The honest version has standalone value. It is not a thin page stretched around an email form. The reader should understand something useful even if they never join a waitlist. That makes the artifact a filter: people drawn to BuildFast's behind-the-scenes explanations are closer to the eventual audience than a broad giveaway audience would be.

The launch connection is simple. A public chapter can lead to a small waitlist. The waitlist can ask which product or problem matters. An approved cohort can use the real product. Their reports return to weekly triage. Content, access, use, and learning become one loop instead of unrelated marketing tasks.

SEO basics that still matter

Solo-founder SEO begins with making a useful public page understandable and crawlable. Google's starter guide emphasizes descriptive page titles and headings, useful content, descriptive links, and pages that search engines can access (Google Search Essentials). No metadata trick can compensate for a page that does not answer the visitor's question.

Each public product or lead-magnet page should have a stable preferred URL. If the same content is reachable through several URLs, a canonical link helps indicate the representative version; redirects and sitemap inclusion reinforce the same choice (Google canonicalization guidance). A sitemap helps search engines discover the public URLs you want crawled, especially as the book or HMD archive grows (Google sitemap guidance).

robots.txt is a crawl instruction, not access control. Google explicitly notes that a blocked URL can still appear in results without a snippet, and private content should be protected with authentication or removal controls instead (Google robots.txt introduction). Slate's signed-in library stays private because the server requires a valid session, not because a crawler is asked politely to leave.

Structured data is useful only when it truthfully describes visible content and follows the relevant Google requirements. Search performance should be checked in Search Console rather than inferred from whether a page “looks SEO-ready” (Google structured-data policies, Search Console). The solo-founder priority is a small set of excellent, indexable pages with clear ownership, not a generated field of near-duplicate pages.

Launching is therefore a controlled system, not a date. Publish something useful. Admit deliberately. Separate access from payment. Promote features through reversible states. Listen in one loop. Make the public explanation crawlable. Then expand only when the evidence stays legible.

On your stack
  • Start Slate, HMD, or Hestya beta access with the portfolio's small waitlist fields and a server-owned cohort; never distribute privileged invitation authority to browser code (controlled-beta path).
  • Use off, internal, beta, and all for risky capabilities, while keeping verified Stripe/RevenueCat entitlement separate from release access (controlled-beta path).
  • For Slate's Apple round, begin with named TestFlight external groups and email invitations; choose a capped public link only after the manual path becomes the constraint (web research).
  • Route reports from the active surface into one weekly queue and classify them as bug, confusing, request, or not for this product before changing scope (controlled-beta path).
  • Treat this book and HMD's public articles as useful, crawlable assets: stable URLs, descriptive titles, canonical choices, a sitemap, and real server-side protection for private pages (Google SEO starter guide, web research).

CHAPTER 16 · SHIPPING

Selling: one-time, subscription, and the education lesson

Should a solo product ask once for money, ask every month, or teach first and sell later?

The answer begins with the promise, not the payment control. A one-time price says that the customer can buy a defined thing and keep the promised access. A subscription says that value will continue to arrive while payment continues. Education says that understanding the system is itself valuable—even when an agent can generate the visible code.

Those promises create different businesses. They change when cash arrives, what the customer expects next, how refunds and cancellations feel, which purchase states the product must reconcile, and how much support follows the sale. They also change the product's relationship with trust. A clear one-time offer can be easier to evaluate. A recurring offer can support recurring work, but only when the product keeps earning it.

Slate has chosen a concrete direction for its unfinished Apple release: a one-time $99 lifetime product after a first-20-items trial. The StoreKit test catalog uses a non-consumable product ID, com.slatereader.lifetime, intended to agree with native configuration, RevenueCat, and Slate's server. That is a planned commercial shape, not a live App Store sale; Slate had not completed production App Store release when verified (Apple release recipe).

One-time, subscription, and education promises connect money rails to one entitlement ledger PROMISE MAP ONE-TIMESUBSCRIPTIONEDUCATION payment oncedurable entitlementfinite includedproduct promise→ upgrades / support policy recurringpaymentrecurringservice / valueentitlement ledgercancellation · failed renewalgrace period · restoreongoing support orbits the ledger public explanation/ lead magnettrustinformed builderproduct or boundedpaid learning SEPARATE MONEY RAILS · ONE APPLICATION ANSWER StripeApple /RevenueCatapplication entitlement ledger PORTFOLIO TIMELINE · CREATOR-REPORTED PRODUCT ARC; NOT AUDITED FINANCIALS ShipFast boilerplateCodeFast courseTrustMRR marketplaceDataFast SaaS
One-time, subscription, and education promises connect money rails to one entitlement ledger

Price communicates what kind of relationship this is

Price is not merely a revenue variable. It tells the customer how to classify the product. A one-time price resembles ownership of a defined tool or edition. A subscription resembles continued service. A free lead magnet asks for attention and perhaps permission to continue the relationship before it asks for money.

For a solo founder, clarity beats a large menu. Every extra tier, billing interval, usage allowance, coupon rule, and exception creates copy to explain, states to test, and support decisions to repeat. Stripe can represent one-time prices and recurring prices, but the provider's flexibility does not mean the product needs all of them (Stripe Prices). Apple likewise supports several in-app purchase types; Slate's committed test configuration deliberately chooses one non-consumable lifetime identity (Apple release recipe).

The psychological advantage of one-time pricing is closure. The buyer knows the amount and does not anticipate another charge. The disadvantage is hidden in the word “lifetime.” Whose lifetime, which features, which platforms, and how much future compatibility or support are included? If the offer is vague, one payment can create an indefinite obligation without recurring revenue to fund it.

The psychological advantage of a subscription is a lower commitment to begin and a visible ability to leave. The harder side is the recurring trust test. Each renewal reminds the customer to compare current value with current price. Cancellation, failed payment, grace periods, refunds, and restoration are not edge cases; they are part of the product promise. Apple's guidelines require subscription apps to provide ongoing value and clearly describe the terms (App Review Guidelines).

Neither model is morally better. A one-time tool that depends on permanent server work can become fragile. A subscription wrapped around a static bundle can feel extractive. The honest fit comes from the cost and value that really recur.

One-time payment buys simplicity and concentrates risk

One-time pricing works well when the customer can understand the delivered value now and the founder can bound what the purchase includes. Slate's proposed lifetime purchase has that appeal: it is one focused reading product, with a simple unlock instead of a recurring decision. A non-consumable Apple purchase is designed to be bought once and restored to the customer's devices, subject to the product and account configuration (Apple in-app purchase types).

Cash arrives earlier, but it does not repeat automatically. Continued hosting, extraction, email, support, operating-system changes, and App Store maintenance continue after the payment. A lifetime offer therefore needs an economic answer other than “future buyers will cover past buyers forever.” It can bound major upgrades, keep service costs low, sell additional products, or treat the product as part of a broader audience strategy. The choice should be stated rather than discovered during a support crisis.

The payment fee also bites differently at different price points and channels. Stripe's United States standard online domestic-card list rate was 2.9% plus $0.30 per successful charge on July 15, 2026. Apple recorded a standard 30% commission for digital goods, with 15% applying to qualifying programs and subscriptions; Small Business enrollment has separate eligibility rules. Those are dated public terms, not the live effective rate for this owner (2026 stack audit).

For Slate, the production price, App Store product, RevenueCat offering, and actual commission still require live verification. ABSENT — resolve by: confirm the $99 non-consumable in App Store Connect, record currency/territory choices, RevenueCat offering and entitlement mapping, Small Business enrollment, refund/support terms, and the web-versus-Apple purchase relationship before public sale. The source recipe calls $99 Slate-specific but does not claim the production catalog is complete (Apple release recipe).

A subscription must fund something that keeps happening

A subscription can align revenue with recurring work. Hosted computation, fresh data, continuous content, active monitoring, storage, team workflow, and regular expert support can all create ongoing value. The important test is not whether the founder has a monthly bill. It is whether the customer receives a recurring outcome.

Recurring billing introduces more state. A subscription can be active, trialing, canceled but still paid through a period, past due, in a grace period, refunded, or expired. Apple and Stripe describe those states differently. RevenueCat can normalize store transactions into entitlements, but Apple still processes Apple purchases, Stripe processes web purchases, and the product still needs a durable answer to “may this account use this feature?” (RevenueCat entitlements, 2026 stack audit).

That is why payment provider state should feed one application access decision. A successful checkout redirect is not durable proof, and Stripe can retry, duplicate, or deliver webhook events out of order. Stripe tells implementers to verify signatures and design webhook handling for asynchronous delivery (Stripe webhooks, 2026 stack audit). On Apple, purchase and restore must reconcile the store transaction to the authenticated product account. A subscription magnifies any ambiguity because access can change every billing period.

The product also acquires retention pressure. Some of that pressure is healthy: it forces the founder to keep delivering value. Some can distort the product into constant feature activity. Retention should come from the repeated job being worth doing, not from making cancellation obscure. Apple's guidelines require clear subscription information and accessible restore behavior; the product should treat those as trust features, not review chores (App Review Guidelines).

HMD has an ongoing publishing and email habit; Hestya's assistant concept implies ongoing software behavior; neither fact by itself chooses a price. ABSENT — resolve by: for HMD and Hestya, write the recurring customer outcome, recurring founder cost, included support, cancellation consequence, and evidence of repeated use before selecting one-time or subscription pricing. Their current hero-surface playbooks define where value occurs, not how it is sold (web research, surface strategy).

The ShipFast arc is a warning about what stays scarce

Marc Lou's products provide a useful agent-era case because ShipFast sold code meant to remove repeated setup, while CodeFast sold learning. According to Marc Lou posts captured in the July 15 research ledger, ShipFast reached roughly $50K per month at its 2024 peak. The same ledger reports his June 2026 post at about $3K for ShipFast and $9K for CodeFast. He also wrote that both were far below their all-time highs (X research ledger, June 2026 revenue post, peak post).

Those exact X figures are plausible-unverified, not audited financials. The ledger's Fable review could not fetch the posts anonymously because X returned a 402 response, so the numbers must remain directional until checked in a logged-in session. They are creator claims about monthly product revenue, not independently verified MRR, profit, or bank receipts (X research ledger).

The more durable evidence is the creator's stated diagnosis. In a May 31, 2026 post, Marc wrote, “AI is making shipfast obsolete.” He said the remaining value was mostly educational—how a codebase is written—and community, while CodeFast taught the minimum needed to start vibe coding and understand the generated code (Marc Lou post, X research ledger). That quotation is also subject to the ledger's plausible-unverified X caveat until a logged-in check succeeds.

The case does not prove that every boilerplate dies or every course wins. ShipFast can still save time, and tested auth, billing, webhook, and security conventions remain valuable. The change is scarcity. Agents can reproduce landing pages, CRUD routes, and common integration glue quickly. They cannot automatically give the owner a trustworthy judgment about what should be wired, how it fails, which authority belongs on the server, or whether the generated behavior was actually exercised.

The research ledger's synthesis is therefore narrower: commodity template code lost scarcity; opinionated tested patterns, conventions agents can follow, architecture judgment, security review, product taste, and distribution still compound (X research ledger). That is the education lesson.

The verified 2026 audit adds a commercial correction: the durable unit is a portfolio of products, not one ebook or boilerplate expected to carry the whole business. Marc Lou's public arc now runs from ShipFast's boilerplate through CodeFast's course into TrustMRR and DataFast; his February 2026 split showed the newer SaaS/marketplace products larger than the boilerplate (portfolio split, product arc, verified practitioner audit). Those are creator-reported product figures and direction, not audited financial statements.

For trust, the audit finds stronger evidence for boilerplate glut and affiliate backlash than for AI eliminating the need for paid starters. Buyers still value exercised auth, webhook, idempotency, and security work, at medium confidence; what lost credibility was another undifferentiated template promoted as a shortcut (boilerplate-market post, affiliate backlash, paid-starter discussion).

Education can outlast the exact code

A boilerplate freezes an answer at a point in time. Framework versions change. Apple requirements change. Vendor SDKs change. An agent may prefer a different implementation next year. If the buyer understands only which files to copy, every change threatens the value of the purchase.

Education carries the reasoning forward. It explains why webhook signatures are verified against the raw body, why RLS belongs in the database, why a capture extension gets narrow permissions, why Cloudflare DNS-only records do not proxy traffic, and why an Apple archive is not an App Store release. The exact code can change while those boundaries still help the owner review what an agent produced.

That is why this book exists. It is not decorative documentation for BuildFast. It is one product in the strategy. Internally, it makes agent output less magical and preserves the decisions already paid for through real Slate, HMD, and Hestya work. Publicly, it becomes the lead magnet for builders who can generate code but want to understand the system they are now responsible for. The book earns attention by being useful before a transaction.

Free tools can carry the same distribution job with more immediate utility. A real grader, checklist, calculator, directory, or scanner lets a builder receive value and discover BuildFast through a problem already being solved; practitioners still describe that engineering-as-marketing loop as a working distribution playbook (free-tool distribution). The tool must operate on real inputs and honest limits—never fake scores or invented benchmarks.

The commercial path can remain bounded. BuildFast's web research recommends keeping the system internal-first, proving it across genuinely new products, measuring support, and considering a versioned documentation or reference product before promising lifetime boilerplate updates (web research, sellability). That avoids turning every internal convention into a customer compatibility guarantee.

Education does not mean withholding the implementation. It means placing the implementation inside a map: what job it performs, what authority it holds, how it fails, what evidence verifies it, and which decision remains human. A customer or reader can then use an agent faster without surrendering ownership.

Choose the promise before the rail

Start with the durable customer outcome. If the promise is a defined tool and the continuing cost is supportable, one-time pricing may be the clearest fit. If fresh value and operating work genuinely recur, a subscription can align the business with that work. If trust and understanding are the scarce inputs, education can lead—free as a lead magnet, paid as a bounded product, or embedded beside software.

Only then choose Stripe, Apple in-app purchase, or both. The rail determines fees, review rules, event shapes, and refund mechanics. It should not invent the value model. Whichever rail accepts money, let it report transactions into one entitlement ledger and make the product read access there rather than trusting a browser redirect or a cached client flag (2026 stack audit).

For this portfolio, Slate's $99 lifetime direction, the BuildFast book, and the unfinished Apple release already form a coherent sequence: explain the system, build trust, sell a focused tool, and keep the commercial promise simpler than the machinery underneath it. The next education product should extend that portfolio only after this free book or a real tool proves distribution; it should not assume one ebook is the business.

On your stack
  • Slate's current plan is a one-time $99 lifetime non-consumable after a first-20-items trial, but the production App Store sale and final entitlement configuration are not shipped facts (Apple release recipe).
  • Stripe and Apple are transaction rails with different fees and rules; RevenueCat can normalize Apple state, but Slate's application still needs one reconciled access decision (2026 stack audit).
  • Use subscription pricing only when HMD, Hestya, or a later product can name the customer value that continues—not merely the founder's recurring expenses (App Review Guidelines).
  • Treat the reported ShipFast $50K peak, June 2026 ShipFast $3K, and CodeFast $9K as directional creator claims: the ledger marks the X numbers plausible-unverified until checked in a logged-in session (X research ledger).
  • Treat this book as one distribution asset in a product portfolio; pair education with useful free tools and sell only the exercised patterns whose trust survives boilerplate-market skepticism (verified practitioner audit).

CHAPTER 17 · APPENDIX

Choosing the stack: the alternatives, honestly

Why Supabase and not Firebase? Why Vercel and not Cloudflare? Are you keeping this stack because it is right, or because switching feels hard?

You answer those questions by comparing jobs, not logos. For each layer, ask what must remain true for the product, what the dated pricing floor really buys, which operational burden stays with you, and what observable event would justify reopening the choice. An alternative can be excellent and still be wrong for your portfolio because it adds a second data model, deployment path, billing truth, or emergency runbook.

The tables below use public USD list prices checked on 2026-07-16, before tax and account-specific terms. A free tier is a floor, not proof that production will be free. The verdicts apply to this portfolio: small Next.js and Apple products, built mainly through agents, with one founder accountable when they fail (alternatives audit).

Backend, database, and auth platform

The job is to keep application data, identity, authorization, files, and server work dependable without making you a database operator. One Postgres path and database-enforced RLS matter more here than novelty.

OptionFloor price, checked 2026-07-16StrengthWeakness
Supabase$0; Pro $25/month (pricing)Postgres, Auth, Storage, APIs, RLSShared projects can share blast radius
Firebase$0 Spark; Blaze $0 base plus usage (pricing)Mobile SDKs, offline sync, Google servicesDocument model and operation billing
Convex$0; Professional $25/developer/month (pricing)Reactive, typed TypeScript backendProprietary data and runtime model
Neon + Drizzle + Better AuthNeon $0; typical Launch $15/month; auth $0 or $20/month managed (Neon, Better Auth)Portable, code-visible Postgres stackYou assemble and operate more seams
PocketBase$0 software; hosting extra (FAQ)Tiny backend and approachable adminSelf-hosted, single-node, pre-1.0

Verdict: keep Supabase. It preserves the relational model already shared by web and Apple, keeps authorization beside the data in RLS, and avoids rebuilding storage, auth operations, and admin tooling from parts. Revisit when a product needs isolated compliance or scaling, shared-project coupling causes an incident, or offline/realtime needs dominate; the first answer is usually another Supabase project.

Migration cost: expect data validation, rewritten clients and auth, new authorization tests, storage remapping, dual operation or downtime, and renewed web and Apple verification.

Web hosting and deploy

The job is to turn commits into preview and production deployments with logs, secrets, domains, rollbacks, and predictable framework behavior. You are buying fewer deployment decisions as much as compute.

OptionFloor price, checked 2026-07-16StrengthWeakness
Vercel$0 non-commercial Hobby; Pro $20/month with $20 usage credit (pricing)Best Next.js preview and rollback pathCommercial use needs Pro; platform coupling
Netlify$0 with 300 credits; Personal $9/month (pricing)Mature previews and broad framework supportCredits obscure marginal cost
Cloudflare Workers/Pages$0; Paid $5/month minimum (pricing)Global edge and low floorDifferent runtime and debugging model
Railway / RenderRailway trial then $1 path; Render workspace $0 plus compute (Railway, Render)Containers and persistent processesMore process ownership; free limits
Fly.io$2.02/month smallest listed shared VM; no ongoing free allowance (pricing)Regional containers and private networksYou own topology, capacity, and volumes

Verdict: keep Vercel as the web default and Cloudflare as a narrow edge lane. Vercel matches Next.js, makes agent-built previews easy to verify, and keeps deploy behavior consistent across 23 inventoried projects. Revisit per service when it needs a persistent process or WebSocket server, edge/static economics dominate, or measured Vercel spend materially exceeds a portable equivalent.

Migration cost: moving one simple site can take days, but portfolio parity requires auditing runtime APIs, cache, images, cron, secrets, redirects, analytics, rollback behavior, and DNS cutover.

Web payments

The job is to accept payment, maintain entitlements, verify webhooks, handle refunds and disputes, and satisfy transaction-tax obligations. The decisive split is whether your LLC remains merchant of record or pays a provider to assume transaction compliance.

OptionFloor price, checked 2026-07-16StrengthWeakness
Stripe2.9% + $0.30 US online card; Tax Basic adds 0.5% (pricing)Deep billing ecosystem and incumbent pathYou retain tax and customer-law duties
Paddle5% + $0.50/checkout, no monthly fee (pricing)Merchant of record includes tax/complianceHigher take and less control
Lemon Squeezy5% + $0.50, possible extras, no monthly ecommerce fee (pricing)Simple digital-product merchant of recordLess billing depth and provider dependency

Verdict: keep Stripe, with a hard tax checkpoint. It preserves the verified webhook and entitlement path, has the deepest agent-readable integration surface, and avoids migrating customers before merchant-of-record outsourcing has a measured value. Revisit before meaningful non-US consumer sales or when a CPA identifies the first registration or filing you do not want to operate; evaluate Paddle first for subscription SaaS.

Migration cost: products, prices, customers, entitlements, terms, webhooks, and finance reconciliation must move, and saved payment credentials may not be portable.

Apple monetization

The job is to sell Apple digital goods and keep renewals, refunds, restores, and cross-device entitlements correct. You need one server-readable entitlement truth, not a second subscription story inside each app.

OptionFloor price, checked 2026-07-16StrengthWeakness
RevenueCat$0 to $2,500 monthly tracked revenue, then 1% (pricing)Normalized receipts, events, entitlements, webhooksVendor fee atop Apple commission
Raw StoreKit 2$0 incremental; Apple membership $99/year (membership)Native control and no infrastructure feeYou own reconciliation and every edge case
Superwall$0 to $10,000 monthly attributed revenue, then 1% (pricing)Paywall targeting and experimentsDoes not replace subscription truth

Verdict: keep RevenueCat. It standardizes Apple entitlement events, gives server consumers one integration, and removes correctness work whose failures affect revenue. Revisit raw StoreKit only if the catalog stays simple and RevenueCat fees exceed the measured cost of a tested subscription backend; add Superwall only for a named experiment with enough traffic.

Migration cost: leaving means rebuilding customer history, notification handling, restore/refund logic, support views, analytics, and parallel validation across shipped versions.

Transactional email

The job is to deliver password, verification, receipt, and lifecycle mail with authenticated domains, suppressions, webhooks, and useful logs. A cheap send is not useful if you cannot explain a missing message.

OptionFloor price, checked 2026-07-16StrengthWeakness
Resend$0 for 3,000/month, 100/day; Pro $20/month (pricing)TypeScript and React Email fitShorter track record and retention
Postmark$0 for 100/month; Basic $15/month (pricing)Transactional focus and troubleshootingAwkward low-volume price jump
AWS SES$0 minimum; 3,000 message charges/month free for 12 months, then $0.10/1,000 plus extras (pricing)Lowest unit costIAM, event plumbing, and deliverability ops
Mailgun$0 for 100/day; Basic $15/month (pricing)Mature API, inbound, validationMore surface than the portfolio needs

Verdict: keep Resend. It matches the TypeScript/template conventions, has the lowest integration burden, and there is no verified portfolio deliverability incident supporting a move. Revisit after a seed test proves a deficit, support needs longer forensic retention, or sustained volume makes SES savings pay for its operations; Postmark is the reliability-first alternative.

Migration cost: you must change mail DNS, warm and monitor reputation, migrate templates and suppressions, rewire retries/webhooks, and test both paths in stages.

Newsletter and broadcast email

The job is to preserve consent, subscribers, segments, automations, unsubscribe state, and the publication’s growth loops. History and sender reputation make this far less portable than an embed suggests.

OptionFloor price, checked 2026-07-16StrengthWeakness
Kit$0 to 10,000 subscribers; Creator $39/month to 1,000 (plans)Mature automations, forms, and taggingList-priced paid tiers; managed recommendation on free
beehiiv$0 to 2,500; Scale $43/month billed annually (pricing)Native referrals, ads, archive, growthPulls product toward hosted ecosystem
Ghost(Pro)Starter $18/month billed yearly (pricing)Publication, membership, SEO, ownershipDuplicates the custom Next.js site
Buttondown$0 to 100; next tier ABSENT — resolve by: set the live pricing slider to the actual subscriber count (pricing)Minimal, writer-friendly, privacy-consciousSmaller automation and growth set

Verdict: keep Kit. It preserves consent/history and existing DNS reputation, keeps working automations, and avoids coupling HMD’s custom site to another hosted web product. Revisit when the newsletter becomes the main business and beehiiv’s growth network is decisive, or when you deliberately replace both site and email tool with Ghost.

Migration cost: subscriber consent fields, tags, sequences, forms, APIs, archives, DNS, warming, and several monitored broadcasts all have to move.

Errors and observability

The job is to detect failures, connect them to releases and user context, and retain enough evidence for an agent to reproduce them without drowning you in alerts. Web and Apple need a shared incident vocabulary.

OptionFloor price, checked 2026-07-16StrengthWeakness
Sentry$0 Developer; Team $26/month billed annually (pricing)Deep web/native release diagnosticsNoise and event cost without tuning
GlitchTip$0 for 1,000 events; Small $15/month (pricing)Open source and Sentry-compatibleShallower diagnostics and mobile tooling
Axiom$0 Personal; Cloud $25/month plus usage (pricing)Fast logs and agent-friendly queriesNot an exception-workflow replacement
Better Stack$0; paid incident response $34/month or $29 annually (pricing)Logs, uptime, status, errors in one suiteLess-proven error-forensics depth

Verdict: keep and standardize Sentry. It spans Next.js and Apple, ties failures to releases and source, and gives agents better evidence than raw logs. Revisit when noise or spend survives sampling and filtering, or when unified logs/uptime becomes the primary job; test Better Stack or Axiom before replacement.

Migration cost: every SDK, privacy rule, source-map/dSYM upload, release tag, alert, dashboard, sample rate, issue link, and baseline needs replacement and dual-running.

Product analytics

The job is to establish one small vocabulary for acquisition, activation, retention, and paid conversion across products. The portfolio has no incumbent, so this is an adoption decision rather than a migration.

OptionFloor price, checked 2026-07-16StrengthWeakness
PostHog$0 for 1 million analytics events/month, then usage (pricing)Funnels, cohorts, flags, replay, surveysInvites over-collection and Sentry overlap
Plausible30-day trial; $9/month hosted (pricing)Clear privacy-oriented web analyticsWeak product journeys and native support
Fathom7-day trial; $15/month (pricing)Simple multi-site traffic analyticsIntentionally shallow product analysis
GA4$0 standard product (terms)Acquisition and advertising attributionComplex operation, consent, and interpretation
None yetNo vendor costNo privacy or maintenance burdenDecisions stay anecdotal

Verdict: adopt PostHog narrowly. It can carry one event contract across Next.js and Apple, its free allowance fits small products, and funnels/cohorts answer questions traffic counters cannot. Revisit after two quarters: use Plausible or Fathom if you only read traffic reports, or pause collection if no product decision changed.

Migration cost: initial cost is a privacy review, event dictionary, consent and identity rules, SDKs, validation, and dashboards; a later move may lose historical meaning even if events export.

Web framework

The job is to give you and your agents one way to route, render, fetch, secure server code, style, build, test, and deploy. Current documentation and learned ecosystem patterns directly affect defect rates.

OptionFloor price, checked 2026-07-16StrengthWeakness
Next.js$0 MIT framework (license)Largest React ecosystem and agent toolingChanging cache/render rules; Vercel affinity
SvelteKit$0 MIT framework (license)Concise components and adaptersSecond UI language and smaller corpus
React Router framework mode$0 MIT framework (license)Explicit web-standard request flowMultiple modes and evolving terminology
Astro$0 MIT framework (license)Content-first static outputLess natural for interactive apps

Verdict: keep Next.js; allow Astro only for a new content-only site with a written boundary. Next.js maximizes component and deployment reuse, and its current agent guidance reduces stale-pattern errors. Revisit after repeated upgrade/cache failures become a measured burden, or when a content site cannot meet its JavaScript budget without fighting the framework.

Migration cost: routing, loading, rendering, endpoints, metadata, assets, tests, adapters, and many components change, with auth and SEO regressions possible behind an identical screen.

Native Apple development

The job is to build universal Apple apps with deterministic projects, signing, tests, App Store delivery, and safe access to native platform features. Since Android is not a committed requirement, Apple fidelity is the actual target.

OptionFloor price, checked 2026-07-16StrengthWeakness
SwiftUI + XcodeGen$0 tools; Apple membership $99/year (Apple, XcodeGen)Native fit and agent-readable project YAMLSigning and lifecycle still demand expertise
Tuist$0 Air; Pro $0 plus usage after thresholds (pricing)Modular graphs, caching, selective testsMore abstraction than small apps need
React Native + Expo$0 tools and Expo Free; Starter $19/month (pricing)TypeScript reuse and cross-platform servicesNative layers and weaker Apple fidelity
Flutter$0 BSD SDK (docs)Consistent cross-platform renderingAdds Dart and non-native widgets

Verdict: keep SwiftUI + XcodeGen. It is the shortest path to native Apple behavior, preserves platform security/accessibility, and keeps project configuration reviewable in text. Revisit Tuist after repeated build or modularity pain; revisit Expo only after Android becomes a funded roadmap, not speculative optionality.

Migration cost: Tuist replaces project manifests and CI; React Native or Flutter is an app rewrite with native bridges, cross-platform QA, analytics/IAP migration, and staged binary replacement.

Auth as a service, if split from Supabase

The job is secure identity, sessions, recovery, social login, abuse controls, and native/web support if identity ever leaves Supabase. An external identity provider still does not replace Postgres authorization or RLS.

OptionFloor price, checked 2026-07-16StrengthWeakness
Clerk$0 to 50,000 monthly retained users/app; Pro $20/month annually (pricing)Polished managed identity and sessionsSeparate user model and step-cost features
Auth.js$0 open source (project)Familiar Next.js flexibilityYou own security operations; weak native shape
Better Auth$0 framework; managed Pro $20/month (pricing)Database and policy visible in TypeScriptMore security-critical application ownership
WorkOS AuthKit$0 to 1 million MAU; SSO $125/connection/month (pricing)B2B organizations, SSO, SCIMEnterprise add-ons are the value and cost

Verdict: do not split; choose Clerk only if a split becomes necessary. Supabase Auth keeps identity aligned with the database and Apple/web clients; Clerk is the lowest-ops replacement if a concrete capability or isolation gap appears. Revisit WorkOS instead when a paying B2B customer requires SSO or SCIM, and preserve tested RLS either way.

Migration cost: identities, passwords where portable, OAuth, redirects, sessions, email, account links, JWT claims, webhooks, support tools, and foreign keys must coexist and reconcile.

Version control and CI

The job is to store source, run builds and tests, protect secrets, manage releases, and keep the agent-to-commit-to-deploy path dependable. The recovery system for every other system should not become another server you maintain casually.

OptionFloor price, checked 2026-07-16StrengthWeakness
GitHub + Actions$0 with 2,000 private Actions minutes; Team $4/user/month (pricing)Largest agent, action, and deploy ecosystemPolicy risk; workflows need pinning and discipline
GitLab$0 with 400 compute minutes; Premium $29/user/month annually (pricing)Integrated SCM, CI, registry, securityHeavier and smaller portfolio ecosystem
Forgejo$0 software; hosting and operations extra (project)Sovereign open-source forgeYou operate source-of-truth and runners

Verdict: keep GitHub + Actions. Existing history and integrations already live there, it has the deepest agent/deploy ecosystem, and managed availability is worth more than forge sovereignty for this portfolio. Revisit for a real self-hosting or data-residency requirement, a material policy/cost change, or a team large enough to benefit from GitLab’s integrated workflow.

Migration cost: code mirroring is easy; issues, review history, Actions, secrets, environments, releases, registries, protections, deploy hooks, and agent credentials make the real move multi-day or longer.

The standing verdicts

LayerIncumbentVerdictRevisit trigger
Backend/database/authSupabaseKeepIsolation/compliance, shared-project incident, dominant offline/realtime need
Web hosting/deployVercelKeepPersistent process, edge/static economics, measured cost gap
Web paymentsStripeKeepUnwanted tax filing duty or meaningful non-US consumer sales
Apple monetizationRevenueCatKeepFee beats owned-backend cost; experiment volume warrants Superwall
Transactional emailResendKeepProven delivery deficit, retention need, SES-scale volume
Newsletter/broadcastKitKeepNewsletter becomes core business; beehiiv/Ghost payoff
Errors/observabilitySentryKeepTuned noise/spend or logs/uptime becomes primary
Product analyticsNoneAdopt PostHog narrowlyNo changed decisions after two quarters; traffic-only use
Web frameworkNext.jsKeepPersistent upgrade/cache burden; content-only Astro case
Native AppleSwiftUI + XcodeGenKeepMeasured modularity pain; committed Android roadmap
Split authSupabase AuthKeep; Clerk only if splitMissing identity/isolation feature; B2B SSO demand
Versioning/CIGitHub + ActionsKeepResidency, material policy/cost change, team-scale GitLab value
On your stack
  • Keep the portfolio’s one Supabase/Postgres/RLS data path; reopen a row only when its own trigger occurs.
  • Keep Vercel responsible for web builds, CDN, and functions, while Cloudflare handles authoritative DNS, Email Routing, and bounded Workers.
  • Keep Stripe and RevenueCat converging into the server-side entitlement ledger rather than treating either provider as authorization.
  • Add PostHog only after the event dictionary, privacy/retention decision, identity rules, and validation plan exist.
  • Treat every price above as a 2026-07-16 snapshot and rerun the audit before a decision depends on it.