Lab note

AUG 19 2026

Building OfferLink: The constraints that let coding agents work autonomously

Raw text for LLM All posts Edit on GitHub

A while back I wrote On How To Write Software, an architecture agreement I hand to my agents before they touch anything. This post is the other end of that. It’s what came out, read back against real code at commit 58715f6.

The product is OfferLink. Small workshops get a hosted intake form and send back quotes. One workspace per workshop. I didn’t hand-write the codebase. Agents did, from the agreement, over a few months of loops.

Customer no account, ever Operator the owner offerlink.me hosted intake & quoting one workspace per workshop Resend dev-log in tests Clerk orgs + operator users sends a request runs the queue transactional email operator identity
The Customer reaches the product twice and signs in for neither. The hosted form is open on the workspace slug. The quote link carries a token whose hash lives in quote_access_tokens, and that token is the whole of the Customer's identity.

So the layer cake below is boring on purpose. Routes, use cases, modules, Postgres. You’ve seen it.

The part I didn’t expect to matter this much is that every module has a written list of what it refuses to do.

A boundary that lives only in prose gets crossed. Someone’s in a hurry, the file is right there, the import resolves. A boundary written as a refusal is different, because crossing it now means editing the README that says you can’t. That’s a speed bump an agent actually notices.

Turned out you can also measure it. More on that near the end.

Four apps, one shared package

npm workspace monorepo. Three frontends, one API, and only one frontend is allowed to know the API exists.

offerlink.me · Docker Swarm behind Traefik, deployed by Dokploy Customer browser Operator browser apps/site Astro, static apps/web React + Vite SPA apps/docs Astro, gated repo docs apps/api Fastify · the only writer unpacked below Postgres Drizzle + migrations media local dir or S3 packages/shared zod schemas, DTO types, pricing & branding domain · compile time only landing form + quote workspace docs JSON + multipart over /api one definition, both sides × no Postgres, no sessions, no API proxy
Not drawn: packages/reels (Remotion video sources) and e2e (Playwright). Build-time workspaces, never deployed. Monitoring runs on a separate host outside the Swarm, on purpose.

apps/site being cut off from Postgres, sessions and the API isn’t an oversight. It’s the refusal that keeps the marketing pages statically prerenderable. The moment site can call /api, someone adds a dynamic bit and the whole build model goes with it.

All of it runs on Docker Swarm behind Traefik, deployed by Dokploy. packages/reels (Remotion) and e2e (Playwright) exist but never deploy.

The outage that moved monitoring out of the box

On 25 July the Swarm manager stalled for about 60 seconds. That was enough to reshuffle tasks and take the prod API down for 15 hours.

The monitoring that should have woken me up was running on that same manager.

It now runs on its own host with its own Docker. Monitoring that shares a failure domain with the thing it watches goes blind exactly when you need it. I knew that. I still had to eat 15 hours to act on it.

The layer cake, and what a route may never do

Routes are transport. Nothing else. Below them the cake forks three ways.

route adapters apps/api/src/modules/{auth, hosted, admin, customer, media}/routes.ts parse · authenticate · open a context · call one thing · map errors · return a DTO readmodels 9 projections read-only, no events use cases 19 named workflows compose modules module facades one capability, one call capability modules business-profile · service-offering · customer-requests work-gallery · operator-access · notifications plans · reminders Postgres tables owned by exactly one module private media storage StorageAdapter: local dir or S3 platform clock ids unit of work event publisher typed errors image sanitizer pass timer seams, not a registry joins across owned tables read-only compose call
19 use cases, 9 readmodels, 8 capability modules, 5 route-adapter folders, counted from apps/api/src. app.ts is the composition root that wires all of it.

19 use cases, 9 readmodels, 8 capability modules, 5 route-adapter folders. Counted from apps/api/src today. app.ts is the composition root that wires all of it.

The edge worth staring at is the long one on the left. Readmodels skip the modules and join straight across owned tables. That’s allowed because they’re read-only, and only because of that.

And the refusal list for routes:

  • No business decision, no lifecycle transition.
  • No raw SQL, no repository access.
  • No manual transaction block for a core workflow.
  • No publishing a domain event directly.
  • No orchestrating several modules inline. That’s what a use case is for.
  • No importing another module’s internal/ files.

One wart I’ll own: modules/{admin, auth, hosted, customer, media} sit in the modules folder for historical reasons. They’re route adapters, not capabilities. The docs say so out loud, specifically so nobody reads them as precedent for putting rules there.

Read path and write path

Same routes folder, two different journeys.

READ · a screen asks a question WRITE · something happens GET /api/admin/cases route adapter auth + tenant context admin-request-readmodel shaped for the queue table Postgres · one joined read DTO out × no writes   × no events   × no invariants   × never a workflow owner POST /api/hosted/…/requests route adapter multipart parse + tenant resolve submit-customer-request one use case, one unit of work service- offering customer- requests notifi- cations rows + history events + queued email committed together, or not at all + snapshot frozen   + events emitted + clock and ids from platform seams
The write lane's three facades are not free to talk to each other. The use case is the only thing that knows the order, and that is the whole reason it has a name.

The three facades in the write lane can’t talk to each other. The use case is the only thing that knows the order, and that’s the whole reason it has a name.

Eight capabilities, and what each one turns down

A module is a durable business capability, never a technical bucket. Four core, four supporting. Here’s the shape, with the refusals that actually earned their keep.

business-profile owns branding (authored values plus tokens derived and frozen at save, with an algorithm version), support email and phone, and the hosted form copy. It refuses operator authentication, catalog rules, the request lifecycle, and notification delivery.

service-offering owns catalogs, items, options, price hints, active state, media metadata, and the immutable snapshot built at submit time. It refuses branding, request status, and anything about quote amounts or tokens.

customer-requests owns the whole Request plus Quote aggregate. One table, cases. Token hashes, line items, amount, currency, expiry, and the seven-state lifecycle with every rule guarding it. It refuses live catalog mutation, storing only a validated snapshot. It refuses letting history become a command model.

work-gallery owns gallery entries and their photos, staged before / during / after, draft and published state, and the rights attestation that published state can’t exist without. It refuses to drive a request lifecycle, refuses to touch request media, and refuses image processing. EXIF stripping happens in platform, before the bytes arrive.

operator-access resolves the authenticated tenant and user, and mirrors the active Clerk Organization into tenants and users. It refuses business authorization rules that a capability owns.

notifications owns attempt rows, their log events, sanitized payloads, and two transports behind one facade (dev-log and Resend). It refuses scheduled email and outbox durability, and that refusal is precisely why reminders exists as its own module.

plans owns tiers as data plus tenants.plan_code. Quote Allowance is counted on read. A slot is spent when a Request is collected, never when a Quote is sent. Monthly windows land on Europe/Warsaw calendar boundaries. It refuses to touch the submission write path at all: never blocks it, never alters it, never observes it. No usage counters, no triggers, no billing state. Read and report only.

reminders owns exactly two scheduled touches per delivery cycle. An expiry warning two days before the offer ends, and a no-view reminder two days after sending. Plus a per-workspace on/off switch where absent means on. It refuses a third kind of touch, and the unique key on (case, cycle, kind) puts that cap in Postgres instead of only in code.

That last one is my favourite pattern in the whole thing. A refusal you can enforce with a constraint isn’t a rule any more. It’s physics.

The doorway and the wall

Every module has the same shape, minus whatever it doesn’t need.

modules/customer-requests/ a use case a route adapter another module work-gallery, say PUBLIC DOORWAY index.ts facade.ts types.ts events.ts errors.ts README.md internal/ repositories.ts quote-lifecycle.ts access-tokens.ts mappers, schema event-builders.ts private, always importing another module's internal/ · refused, no exceptions
The composition root wires facades explicitly and passes them as dependencies. Operation context carries who and how, and is deliberately not a module registry.

Nothing under internal/ is exported. A caller can’t accidentally depend on how a module stores things, because it can’t see how a module stores things.

The composition root wires facades explicitly and passes them as dependencies. Operation context carries who and how (transaction, actor, trace, clock, ids, event publisher) and it’s deliberately not a module registry. The second you can reach any module from context, the graph you drew stops being true.

One table, one owner

Readmodels join across freely. A module only writes its own. Two rows break the pattern on purpose, and both are marked as such.

OwnerTables
business-profile + operator-accesstenants, users (shared, split by column)
service-offeringcatalogs, catalog_items, catalog_media_assets, forms (split)
customer-requestscases, quote_access_tokens, media_assets, events
work-gallerygallery_entries, gallery_media_assets
remindersquote_reminders, quote_reminder_settings
plansplans, tenants.plan_code
legacy, no ownersessions

The tenants split: business-profile owns branding, support and delivery-fee columns. operator-access owns the Clerk mirror. Clerk stays the source of truth for membership and roles. The forms split: slug, status and active catalog belong to service-offering, while title, intro, submit label and confirmation copy belong to business-profile.

On cases, migration 0015 merged the request and the quote into one table, and future schema changes have to preserve that. There’s no quotes table and no quote id. “Quote” is product vocabulary for a projection of that row. Every history row hangs off events.case_id, and there is no events.quote_id.

sessions is pre-Clerk storage. Nothing authenticates through it any more. Only workspace deletion and the demo seed still touch it, and it’s listed as ownerless so nobody assumes otherwise.

Seven states, and two ways to expire

Seven states on a single cases.status column, and the module rejects every transition that isn’t drawn.

new reviewing sent accepted customer says yes declined customer says no withdrawn operator pulls it expired deadline passed operator toggles send send without opening it first give it a new date and send again, the only way back out of a terminal state
Expiry is the only transition with two triggers. Both routes loop the same single-Quote path, so they emit the same event and cancel the same reminders.

Expiry has two triggers. It’s lazy first: the transition happens the next time anybody looks at a sent Quote past its deadline. Then an hourly sweep closes the ones lazy expiry can never reach, the offers nobody opens. That sweep is capped at 500 rows a pass and loops the same single-Quote path, so both routes emit the same event and cancel the same reminders.

Two things hang off this drawing that aren’t states. A Quote Allowance slot is spent at the far left, when the Customer submits, never at send. And entering sent opens a delivery cycle, scheduling two reminders and cancelling the previous cycle’s. A first view cancels the no-view touch only and leaves the deadline warning alone.

The part where the refusals became measurable

Here’s the finding that changed how I read everything above.

No module imports another module. Not once, in any of the eight. In-degree and out-degree of zero toward siblings. A module-to-module graph would be eight boxes and no arrows.

I checked by grepping each module folder for an import of any sibling. Nothing. Not a facade, not a type, not an internal/ file. Modules also never receive each other as constructor dependencies. app.ts builds them from a pool, the ids seam and the clock seam, then hands them to use cases.

The graph exists one layer up, and it only points one way. A use case calls a module, a module never calls back. Modules are sinks. That single rule is what stops a cycle from ever forming.

The second direction is what the call is for. Some modules get asked for facts and hand something back. Others get told to act and return nothing anyone waits on. Sort the eight by that and you get a gradient.

ASKED FOR FACTS THE ONLY CALLERS TOLD TO ACT 19 use cases every arrow in this drawing starts here business-profile asked by 7 · told by 1 operator-access asked by 7 · told by 1 notifications told by 7 · asked by nobody reminders told by 10 · asked by nobody calls facts come back calls nothing comes back ASKED AND TOLD customer-requests asked by 6 · told by 13 service-offering asked by 3 · told by 3 work-gallery asked by 1 · told by 2 plans no edge at all × not one module box has an arrow leaving it
Two directions, drawn separately on purpose. The solid arrow is the call, and it always runs use case to module. The dashed arrow is the answer coming back, and it exists only on the left.

The gradient is the design, not a coincidence. Identity and configuration get asked. The aggregate gets asked and told. Delivery only ever gets told. A workflow reads left to right through it, and a module drifting across would show up here before it showed up in a bug.

Some things that fell out of counting:

customer-requests is the hub, and it’s the only column heavy in both directions. Asked by 6, told by 13. Expected for the module owning the aggregate, and it’s exactly why its refusal list is the longest one in the codebase. A hub without refusals becomes the god object.

plans has zero edges in either direction. One admin route reads it and that’s the entire footprint. Which turns its stated refusal, never touch the write path, from a promise into a measurement.

work-gallery composes with nobody, and it still stores a case_id pointing at a Request plus a source_media_id on every photo copied out of one. A reference without a dependency. The row remembers where a photo came from while the module stays unable to read the Request side.

One caveat on those numbers, because it bit me. They come from each use case’s declared facade dependencies, not from its call sites. Reading call sites alone misses reminders in three workflows that pass the facade straight to a shared helper, and undercounts that column at 7 instead of 10.

One workflow up close

Take send-customer-quote, one of the five-module rows. There’s a line through the middle of it.

ONE UNIT OF WORK · all of it, or none of it 1 customer-requests.sendQuote status becomes sent, an access token is minted and hashed 2 notifications.queueCustomerQuoteSent writes an attempt row, this is not the email 3 reminders.scheduleQuoteReminders opens the delivery cycle, cancels the previous one COMMIT AFTER COMMIT · delivery may fail 4 three reads, in parallel customer-requests: who to send to  ·  business-profile: how it should look operator-access: who it replies to 5 notifications.sendCustomerQuoteReady the transport runs here, and only here × a failure below the line is logged, never rolled back. The Quote stays sent
Everything above the line is one transaction. Everything below it is delivery, and delivery is allowed to fail.

Read the ordering as a rule. A Quote is never sent with nothing watching its expiry, because the reminder rows commit in the same transaction as the status change.

That pattern generalises to every write. Facts commit inside the unit of work. Anything reaching the outside world happens after it, where a failure costs a log line instead of a rollback. It’s also the seam that makes the write path testable, since the three steps above the line run with fake facades and no network.

Steps 1 to 3 also show the quartet that repeats across seven of the 19 workflows. When an email leaves the system, the workflow asks business-profile how it should look and operator-access who it replies to, then tells notifications to send it. Four modules, none of which know the other three exist, arranged so the two config modules can never be the thing that sends an email.

The frontend has the same seam

apps/web splits along a testing seam, not a folder convention.

routes/ holds React components: JSX, wiring, effects. features/ holds the view logic those components render, written as plain functions with no React in them.

routes/ · React pages, one folder per surface hosted  ·  quote  ·  gallery  ·  admin  ·  auth  ·  onboarding JSX, wiring and effects only features/ · view models admin · forms · gallery · timeline · media catalog-photos · i18n pure functions, unit tested without rendering components/ offerlink/ · branded parts: status band, tenant logo, panel section, public loading screen ui/ · shadcn primitives, tokens from tailwind.config lib/ · the app's plumbing api-client · app-router · analytics · auth-config · tenant-branding · boot-api-base boot-api-base resolves the split-origin API host, so the first painted frame is already branded @order-buddy/shared the same zod schemas and pricing rules the API imports. One definition of a line total, not two
Route URLs and DTO contracts are treated as protected surfaces. A refactor may not change them without sign-off, and that is what lets the frontend and backend be reshaped independently.

That’s why features/ has 33 test files and every one is a plain .ts. The view logic runs without a renderer. Across the whole app it’s 54 function tests to 2 component tests.

lib/ holds the plumbing, including boot-api-base, which resolves the split-origin API host so the first painted frame is already branded. And @order-buddy/shared gives both sides the same zod schemas and pricing rules, so a line total has one definition instead of two.

Route URLs and DTO contracts are treated as protected. A refactor can’t change them without sign-off. That constraint is what lets the frontend and backend get reshaped independently.

What I take from this

Three things stuck.

Refusals beat responsibilities. “This module owns X” tells you nothing about the call you’re about to write. “This module refuses Y” stops it.

Put a refusal in the database when you can. reminders capping itself at two touches per cycle is a unique key, not a code review.

And write the counts down. Once the boundaries were explicit, checking them was a grep and a count of declared dependencies. Zero sibling imports across eight modules isn’t something I designed on day one. It’s something I found out was true, then wrote down so I’d notice when it stops being true.

The docs I generated this from carry a kind field: truth, not law. When the structure changes, the doc changes with it. Known drift right now is the checked-in C4 component view, which predates work-gallery, plans and reminders. Everything above includes them.

If you want the setup this runs on, that’s in the box and the loop.

Newsletter

Get the next note by email

One mail per new post. Build logs, architecture notes, and AI workflow experiments.

Double opt-in. Unsubscribe with one click. Your address stays in Kit and goes nowhere else.What you get.