Flow-Through

Flow Through Logic/Published work/Flow-Through Platform

Flow-Through PlatformLive

Multi-tenant CRM and automation engine

The engine underneath every client deployment: contacts, automations, billing and messaging on one tenant-isolated core.

  • 23 service flows
  • 33 test suites · 461 tests
  • Per-tenant isolation

The problem

An enquiry lands at nine on a Friday night and waits until Monday. A quote goes out and the follow-up stays on the mental list. An invoice passes its due date and sits there. The waiver is a paper form in a drawer, the member list is a spreadsheet, the customer’s history lives inside one person’s inbox, and the calendar is somewhere else again. Each gap has a product you could buy, and buying seven of them leaves seven half-pictures of the same person.

What it does

One core carries every client deployment, and the spine of it is a single contact record. A person exists once, as one row, and every automation attaches its work to that row: the waiver they signed, the invoice they owe, the class they chose, the enquiry they sent on a Friday night. The customer picture fills in as a byproduct of the automations doing their jobs, so the CRM builds itself out of work the business already needed done rather than out of an afternoon of data entry.

Contacts arrive through one ingestion path that every channel funnels into: a polled mailbox, the public member pages, an owner typing a name at the counter, and a calendar channel whose interface is fixed and waiting on a credential that carries the scopes. That path dedupes on name plus contact rather than on address alone, which is the difference between two siblings booked under one parent’s email staying two people and quietly collapsing into one. Above the spine sit the automations the platform runs: after-hours enquiry response with intake questions, quiet-thread follow-up, review requests on paid invoices, invoice chasing, appointment reminders, waiver onboarding, the monthly plan cycle with selection and settlement, payment receipts, and a guard that fires whenever a member’s contact details change.

What a client sees is configuration rather than a bespoke build. The same modules light up per business against per-tenant config: memberships and plans, digital waivers that capture per-section read evidence inside the signature, kiosk check-in behind a PIN, and scheduled runs on that business’s own hours. Stripe Connect carries live charges and payouts on the client’s own connected account. Microsoft Graph reaches the owner’s own mailbox, and Resend carries the platform’s sending channel for a tenant configured to use it, which is how a business sends while its own mailbox is still to be connected.

How it works

One Node process runs on Railway against one PostgreSQL database, with the job queue and the HTTP surface sharing that process. A trigger enters at the top, passes through a decision that stays entirely in memory, and leaves through an executor that performs the work and writes the record of it.

  • The contact spineOne row per person, carrying every automation’s data on the same record. Ingestion for email, calendar, public forms and counter quick-add all funnels through a single path, so one shared family address still resolves to the right individual.
  • Data events as the triggerA record created or a status changed writes a ledger row and an outbox row inside the same transaction. A dispatcher drains that outbox every minute, plus an on-demand nudge from the API, so the trigger behaves identically whether the change arrived by email, by form, by a dashboard action, or by someone typing at the counter.
  • Pure decision templatesEvery automation’s decision is a pure function of tenant config, the trigger payload and the clock. It runs entirely in memory and returns a decision plus a plain-English summary, which is what makes hundreds of cases cheap to run on every change.
  • The chase coreOne engine handles every obligation that needs following up: something pending, a fact that will resolve it, a cadence, and a cap. Sends roll forward into business hours, and the final rung raises an owner-attention flag so the chase finishes in a person’s hands.
  • The send transactionSweeps are advisory and the send transaction is authoritative. It takes a row lock on the obligation, re-checks the status and the suppression ledger, records the send, commits, and only then calls the provider.
  • The executorExactly one run row is written per trigger, carrying a status of sent, skipped, held or error alongside a summary written for the owner rather than for a log reader. The client dashboard reads those rows directly, so the record the business sees is the record the engine wrote.
  • Money and messaging at the edgePayments run as direct charges on the client’s own Stripe connected account, with webhook signatures verified over the raw request body. Outbound mail leaves through the channel the tenant’s config names, and through the owner’s connected mailbox when config names none. A tenant carrying neither resolves to nothing, and the lane holds the run and says so in plain English.

What was hard

The double-send window, and the lost send behind it

The nudge lane originally sent the follow-up and marked the thread afterwards. When a send succeeded and the thread write then threw, the next sweep still saw a thread in the answered state carrying its old nudge count, and sent the same follow-up a second time. Marking before the send closes that window and opens its mirror image: a failed send leaves the mark committed and the message quietly gone. The lanes now mark first and compensate on a failure the next pass could clear, restoring the original count and, the subtler half, the original outbound timestamp. That mark stamped the timestamp with the current time, and the quiet-window decision measures from exactly that stamp, so a rolled-back nudge holding the new value would sit out a whole window instead of retrying on the next pass. The same mark-then-compensate shape now runs in the review, receipt, waiver, invoice-chase and appointment lanes, each with the regression test that landed beside it.

A credential refusal spent every member’s rung

A chase is capped: the cadence length is the hard limit, and past it the obligation moves to the owner’s attention list rather than being sent again. That cap made a tenant-wide fault expensive. When a sending channel was refused, by a revoked refresh token, a rejected API key, a mailbox renamed, or a mailbox deleted outright, the sweep walked the rest of the roster into the same wall and each member spent a rung on a fault none of them owned. Reconnecting the channel brought none of them back. The resolution is a two-tier classifier at the send boundary. The first tier separates a send that was lost from one the provider will refuse identically forever, and reads anything arriving without a numeric status as transient, because retrying beats dropping a member’s message on a guess. The second tier asks the narrower question, reading the failed operation as well as the status: Microsoft’s identity platform answers a revoked refresh token with a 400 on the token request, while a 400 from the send call itself is one recipient’s malformed message, and a 404 on the send path means the sending mailbox is gone. On a credential refusal the sweep now stops for that tenant with nothing consumed and one throttled alert, and the next pass picks the roster up exactly where it stood.

Safe to leave running

Every trigger leaves a record, and the failure path is instrumented as carefully as the success path. The executor writes exactly one run row per trigger with a plain-English summary, and a template that fails validation stops before sending: an unresolved merge field or a recipient that fails the check lands as an error run with nothing sent and an alert raised. A run that decides to hold says so in the same ledger as a run that sent, so a deliberate hold and a silent stall look different from the outside.

Automated sends are capped by design. A two-day floor holds between chases whatever config asks for, and the cadence length is a hard cap whose last rung raises an owner-attention flag, so the system stops loudly rather than nagging forever or going quiet. Opt-outs live in a suppression ledger the send transaction re-checks under a row lock at the moment of sending, rather than trusting the sweep that queued it. Delivery rolls forward into the business’s own hours, so an enquiry arriving late on Friday is followed up on Monday morning.

Repetition is designed out at the inbox. A polled mailbox checks every message against a seen-messages ledger, so a message seen twice draws one reply, and the five-minute overlap on the poll cursor stays harmless by construction. A paused automation consumes incoming mail quietly and resumes from the moment it is switched back on, so unpausing picks up from now and leaves the stale backlog where it lies.

Money settles away from the platform. Charges run as direct charges on the client’s own connected account, so funds land with the client and stay clear of the platform account. Webhook signatures are verified over the raw request body, and where the webhook secret is absent verification throws and the route answers 503 rather than trusting an unverified event. Settlement additionally requires that the checkout session actually reached paid and that the tenant carries a connected account, and a checkout that fails to open falls back to bank transfer.

Tenants are isolated at the data layer rather than by convention. Row-level security scopes every client-readable table to the tenant owned by the authenticated user, while the engine’s own state tables, thread memory and the seen-message ledger, stay engine-only. Provider refresh tokens are encrypted at rest with AES-256-GCM. Member links are 32 random bytes held as a SHA-256 hash, scoped to one purpose and expiring, rotated so exactly one live link exists per member per purpose; the pages behind them are write-mostly, prefilling a first name and masked hints while sensitive fields travel inbound only. Kiosk PINs are hashed with scrypt under a per-PIN salt and verified in constant time.

A member changing their contact details through a link is treated as a security event rather than as an update. The previous address receives a plain notice that the details changed, which is what reaches the real member if a link were ever hijacked, and the owner’s attention list gains a flag. That notice sits outside the suppression ledger on purpose, because it is a security notice rather than a send.

Faults stay legible instead of flooding. A flapping connection alerts at most once per connection every thirty minutes, so a mailbox that drops for an hour raises a line or two rather than the thirty its two-minute poll would otherwise write. Deploys survive their own overlap: the HTTP server binds before the queue starts, since the cutover healthcheck is deliberately free of database access, and the queue then boots behind a retry gate that the request handlers await, so a call arriving mid-cutover waits and then succeeds.

Evidence

  • 23 service flows
    A listing at the top of src returns 41 TypeScript files: the 23 the figure counts, and the 18 test files sitting beside them. The 23 run from the flow modules, enquiry, nudge, review, invoice chase, appointments, waivers, plan cycle and contact change, through the member pages and token minting to the shared parts every flow leans on, config and database access and the executor among them. The README’s repo-layout section walks 19 of the 23 with a line each; kiosk-pin.ts, plan-cycle.ts, stripe.ts and types.ts arrived after it was last rewritten.
  • 461 tests across 33 files
    Git tracks 33 files matching *.test.ts under src, carrying 461 cases between them. The last run written into the commit record, at 5b26cfd, reports 456 passing and 3 skipped across 32 files, and the commit after it adds two nudge tests, which puts the suite at 458 passing and 3 skipped across 32 files plus the one that skips. That skipped file is the accept_agreement suite: it runs against the production database inside a transaction that always rolls back, and it sits out any run started without a database URL in the environment. The decision core is pure, which is what keeps that many cases cheap enough to run on every change rather than nightly.
  • Per-tenant isolation
    A control rather than a count, and checkable in the migrations. Migrations 002, 003, 004 and 007 switch row-level security on for every table the engine creates, and 002, 006 and 007 add the owner SELECT policies, each written as tenant_id in (select id from tenants where auth_user_id = auth.uid()). The engine’s own state tables, thread memory and the seen-message ledger among them, carry the switch and no policy at all, which is what keeps them engine-only. MIGRATIONS_APPLIED.md records which migrations ran against the production database, when, and the post-apply check that found row security on for every table in the public schema with zero policies on the engine-only ones.
Every system