Independent architecture review

Inreachly OS: an independent look at the system before you sell it to other people

You asked whether the technical direction makes sense before developers start on the detail. The direction is broadly sound. The problem is that the direction describes a journey you are already halfway through, and the half-finished state is where nearly all of the risk currently sits.

7 critical17 high5 medium

The short version. Inreachly works. That is not in question and it is not a small thing — it sends real campaigns, books real calls, and it is roughly ninety percent feature-complete. What follows is not a list of things you did wrong. It is a list of the things that are true of every system built fast by one person, which only become dangerous at the exact moment things start going well.

The one-line answer to your question. Yes, give this direction to your developers. But your architecture document reads as though you are choosing a future stack. You are not — you have already built most of it. Next.js, Trigger.dev, Mastra, Supabase and Sentry are all installed and running today. What you actually have is a migration that is half-done and stalled, with the old system and the new system both live at the same time, and no written definition of when the old one gets switched off.

The gap between a thing that works for you and a thing that works for strangers is roughly nine times the effort of building it in the first place. You have paid for one part of that. You are selling the other eight.

The three things I would fix before anyone outside your company gets an account. First, your database has no way to record that someone unsubscribed — the status value is literally rejected by a constraint — and there is no suppression list anywhere. Second, the default sending path can send the same email twice after a dropped connection, because it omits an idempotency key it already accepts and treats an unknown outcome as a plain failure; the safe path that does this correctly exists and is switched off by default. Third, one customer's large import can stall sending for every other customer, because job concurrency is shared platform-wide rather than per workspace. All three are small changes. None of them is a rewrite.

What I am not able to tell you yet. I have had no access to the Supabase database. Everything below is read from your GitHub repository, which means I can see what the code *intends* to do, and I cannot confirm what the live database *actually does*. Wherever that distinction matters, I have said so explicitly rather than guessing. Getting me read-only database access is the single highest-value thing you can do to make the rest of this review sharper.

How to read this. Every finding says what I looked at, so you can check any of it yourself. Where I might be wrong, I have said that too — an audit that only tells you what you want to hear is worth nothing, and so is one that inflates every issue into a catastrophe. The last two sections answer your nine questions directly and recommend an order of work.

A word on how this was produced, because it should affect how much you trust it. Five independent reviews ran across different parts of the system — security, database, reliability, engineering process, and data protection. Each was told to attack its area and to argue against its own findings. Several downgraded their own conclusions on closer inspection: identifiers that looked enumerable turned out to be random, a test suite that looked thin turned out to be genuinely good, and a database concern that looked like live drift turned out to be a dead folder. Those corrections are in the findings below. Where something could not be verified, it says so rather than guessing.

Orientation

What the system actually is

Before the findings, it is worth being precise about what Inreachly is today, because your own architecture document does not quite describe it.

Your document describes a plan to adopt Next.js, TypeScript, Trigger.dev and Mastra. All four are already in the repository and in use. The frontend is a working Next.js application with 80 API endpoints. Trigger.dev is running 24 background jobs. Mastra is wired into personalisation, reply classification and lead-magnet generation, with its own evaluation harness. Sentry is installed. Stripe is installed. This is not a proposal — it is the current system.

What the document under-describes is the other half. There is a second, entirely separate backend in the same repository: a Python FastAPI application with 25 route files, 40-odd database modules, its own admin web interface, and 31 scheduled jobs running on Railway. Your architecture document mentions this only as "some important working functionality is currently written in Python" that will be kept "behind clear boundaries". That understates it considerably. It is not a component. It is a second application, and it is the one your Procfile actually starts.

The Python systemThe TypeScript system
What starts itProcfileuvicorn src.mcp_remote_server:appfrontend/ → Next.js on Railway
Web endpoints25 router files80 API routes
Scheduled work31 Railway cron jobs24 Trigger.dev tasks
Incoming webhooksBison, Clay, GoHighLevel, InstantlyA separate /api/webhooks/ tree
Admin interface14 web_server_*.py filesThe Next.js dashboard
Billingsrc/routers/billing/Stripe in the frontend

Both talk to the same Supabase database. I confirmed that they write to the same tables — the follow-up queue is referenced 7 times in the Python code and 92 times in the TypeScript code. Two codebases, in two languages, with two schedulers, writing the same rows, and no single place that owns the rules for those rows.

And there are six reconcilers. A reconciler is a job whose entire purpose is to go and check whether reality matches what the database thinks, and fix it when it does not. You have six of them across the two stacks. Nobody writes one reconciler for fun, let alone six. Their existence is the system telling you, in its own handwriting, where it does not trust itself.

This is the single most important thing to understand about your architecture, and it reframes almost every question in your call guide. You are not deciding whether to adopt a new stack. You are deciding how to *finish* a migration you already started.

Critical Finding 01

Any customer can read every other customer's prospect list

Three tables holding prospect emails and mobile numbers are readable by any logged-in user of any workspace. This is not an exploit — it is the normal database API doing exactly what its rules permit.

Supabase publishes your database directly to the internet as an API. There is no server code standing in front of it. The only thing that stops customer A reading customer B's rows is a set of per-table rules called Row Level Security — think of a doorman standing on each table, checking every row before handing it over.

On three of your tables the doorman has been told to let everyone through. The rule literally reads USING (true), which means "yes, to anyone who is logged in". The tables are pipeline_businesses, pipeline_contacts and pipeline_owners. Between them they hold email, mobile_phone, store_phone, owner_name, first_name and last_name.

There is a second problem underneath the first, and it is the one that actually matters. None of those three tables has a workspace_id column. There is no field on the row saying which customer it belongs to. So this is not a case of someone writing a lazy rule that can be tightened in an afternoon — a correct rule *cannot be written* until the column exists. The permissive rule and the missing column are the same defect wearing two hats.

The same pattern appears on api_usage_tracking, where the rule is auth.role() = 'authenticated'. That checks the visitor is logged in and then treats being logged in as permission. Every customer can see every other customer's API consumption — a direct read on a competitor's volume and spend.

Why this matters in plain terms

For a cold-email business, the prospect list *is* the product. It is the most commercially sensitive thing your customers own, and several of them may be competitors of each other inside the same coaching community.

A customer does not need any technical skill to do this. They need a trial account and the browser console. There is no alarm, no log entry that looks unusual, and no way after the fact to prove it did not happen.

This is also the finding that makes the Kaizen launch dangerous specifically. A cohort from one community all get accounts at once, they all know each other, and one curious person is all it takes.

What I actually looked at

  • Three permissive read rules, confirmed still in force by replaying all 75 live migrations in ordersupabase/migrations/20260801093210_production_baseline.sql:19124,19131,19138
  • Cross-tenant billing visibility, same patternsame file:18134 — api_usage_tracking
  • Confirmed those tables carry no tenant columnparsed from the table definitions in the same migration

Where I could be wrong

This describes what your migration files say. Nobody has had access to the live database, so I cannot confirm the rule is still attached in production — someone may have changed it by hand. One query settles it: select tablename, policyname, qual from pg_policies where schemaname='public';

Please run that before acting on this, and before I put a number on anything.

Critical Finding 02

The check that decides whether a change is safe to merge examines nothing, and says yes

You built a merge-readiness gate. Because of one line, it filters its list of required checks down to empty for almost every kind of change — and an empty list counts as everything passing.

The script that decides whether a change is ready to merge holds a list of the checks it considers its responsibility. That list contains exactly one item: Supabase Preview.

For any change that is not a database migration, the gate takes the checks that ought to apply, keeps only the ones on its own list, and is left with nothing. It then asks "did anything fail?", finds nothing, and reports success.

This is not a theory. The reviewer ran your own script against a simulated live-send change with every routed check explicitly failing, and got back:

{"ready": true,
 "reason": "all required checks passed",
 "required_checks": [],
 "failed_checks": []}

There is a second, independent proof that costs nothing to check. The gate is configured to wait up to 40 minutes for checks to settle. Its actual median runtime is 25 seconds, with 99 of the last 100 runs finishing in under two minutes. It is not waiting for your tests. It has nothing to wait for.

The reason this happened is visible in a comment in the file: the author assumed the real enforcement lived in GitHub's branch protection settings, and that this script only needed to cover the one check protection did not. That assumption may even have been true once. Nothing verifies it now.

Why this matters in plain terms

This is the single most consequential finding in the review, because it is the thing that makes every other safety measure optional. You have good tests. They run. Their result does not reach the decision that matters.

It also explains something that would otherwise look like carelessness. Changes are merging fast with no review — not because anyone decided to skip review, but because an automated gate has been telling everyone, accurately as far as it knows, that everything passed.

What I actually looked at

  • The one-item list that empties the required checksmerge_readiness.py:26 — READINESS_OWNED_CHECKS = {"Supabase Preview"}
  • The filter and the pass conditionmerge_readiness.py:65-69 and :86
  • Executed against a failing live-send change — returned ready:truereviewer ran the repo's own script
  • Median 25s runtime against a 2400s wait budget, 100 runs sampledGitHub Actions run history
  • A test asserts the empty list is correct behaviourtests/scripts/test_merge_readiness.py

Where I could be wrong

There is one thing nobody could check: your branch-protection settings are not readable without an admin token. If protection genuinely does require the full check list, the damage is limited to this gate being decorative rather than dangerous.

But two merged pull requests prove at least one check is not required — #948 and #946 both carry a final failure on the classify check and merged anyway, in 3 and 5 minutes. So protection is not covering everything.

Ten minutes in your repository settings resolves this completely. It is worth doing before anything else on this page, because it decides how serious this finding is.

Critical Finding 03

Your database cannot record that someone unsubscribed

There is no do-not-contact list anywhere in the system, and the lead table is configured in a way that physically rejects the value "unsubscribed". A prospect who opts out still has their remaining follow-ups queued.

This is the most serious finding in the review, and it is not a bug in the ordinary sense — it is a missing part.

Across all 176 tables there is no suppression list, no do-not-contact table, no blocklist. The word does not appear in your schema once.

Worse, the lead record has a rule listing the statuses a lead is allowed to have. That list contains seventeen values. unsubscribed and do_not_contact are not among them — attempting to store either is rejected by the database.

So when someone unsubscribes, here is what actually happens: a counter on the campaign is incremented, a daily metric is updated, and a row appears in the inbox so an operator could filter for it. The lead itself is not touched. Their queued follow-ups remain approved, with send times in the future.

The code says so itself: *"Bison already unsubscribed the lead on its side; this gives operators a filterable tag in the inbox."* The whole design assumes your sending provider is handling it.

And that assumption has a hole in it that nobody can close from inside this repository. Your follow-ups are not sent through the campaign channel. They are sent as threaded replies to a different endpoint. Whether your provider applies its unsubscribe list to *that* endpoint is not documented anywhere in your code.

There is a knock-on effect too: because these statuses cannot exist, a recovery guard that filters on them is checking for values that can never be present — dead code that reads as a safety check.

Why this matters in plain terms

Continuing to email someone after they unsubscribe is the one mistake in this industry that is both a legal exposure and a reputational one. It generates spam complaints, and spam complaints damage your customer's sending domain permanently.

The customer experiences this as your product doing it to them. They will not distinguish between your platform and your provider.

There is a second, quieter problem. Even if your provider does block the send, you have no record. When a customer asks you to prove that an unsubscribed prospect was never contacted again, there is no table you can point at. That is a difficult conversation to have with 30 customers watching.

The fix is small relative to the risk — add the two values to the allowed list, write them when the unsubscribe arrives, and check a suppression table before every send. One migration and one guard.

What I actually looked at

  • No suppression, unsubscribe, DNC or blocklist table exists in any of 176 tablessupabase/migrations/20260801093210_production_baseline.sql
  • The status rule that rejects 'unsubscribed' and 'do_not_contact'same file:7160 — leads_lead_status_check
  • The unsubscribe handler increments counters and never updates the leadbaseline.sql:2905-2946 (RPC body)
  • The operator unsubscribe route writes nothing to your own databasefrontend/src/app/api/leads/bison-sync/route.ts:124-133
  • Follow-ups go via a threaded reply endpoint, not the campaign channelfrontend/src/trigger/utils/bison-client.ts:46
  • The complete pre-send stop-list — suppression is not consultedfrontend/src/lib/follow-up-engine/spine-email-store-safety.ts:54-65

Where I could be wrong

One thing genuinely could not be determined from the code: whether EmailBison applies its own unsubscribe list to the threaded-reply endpoint your follow-ups use. If it does, the practical risk today is much lower than this finding implies and the issue becomes record-keeping rather than active harm.

That is a question for your provider, in writing, and it is worth asking this week. The answer changes how urgent this is — but not whether it needs fixing, because relying on an undocumented behaviour of somebody else's system is not a control you can offer a customer.

Critical Finding 04

You built the safe sending path, and then left it switched off by default

There are two send paths. One is genuinely excellent. The other can send the same email twice after a dropped connection — and it is the one every new customer gets.

Start with the good news, because it is substantial. Your newer sending path — the "spine" — does this properly. It claims the send in the database in a way that can only succeed once, so a retry physically cannot reach the provider a second time. It takes a fingerprint of the email body and refuses to send if anything changed. When an outcome is genuinely unknown, it stops rather than guessing. That is textbook, and it is better than most platforms manage.

The problem is that this path only runs for workspaces on an allow-list, and the allow-list defaults to empty. A new customer is on the older path until somebody edits an environment variable and redeploys. There is nothing in the product that makes this visible.

On that older path, the send function is called without the idempotency key it accepts — so the safety header is never sent. And its error handling catches everything and treats it all the same way.

That last part matters more than it sounds. Your codebase already draws the correct distinction: it has one error type meaning *the provider definitely rejected this*, and another meaning *we do not know whether it went out*. Lost connections, timeouts and server errors are all correctly classified as unknown. The older path checks for neither. It reverts the email to "ready to send" and reports that you can retry.

The background job then retries, five seconds later, and because nothing about the record changed, it claims it again and sends again.

Why this matters in plain terms

A duplicate email is not a cosmetic bug in cold outreach. It is the single fastest way to generate spam complaints, and complaints damage the sending domain for every campaign that domain carries.

The failure is also correlated. It does not happen once to one prospect — it happens when the provider has a bad minute, to everything in flight at that moment, across every customer.

And there is a sharper version of the same problem: the operator "send now" button takes the old path even for customers who are on the new one. So the safest workspaces still have an unguarded route into the provider.

What I actually looked at

  • Send called without the idempotency key the function acceptsfrontend/src/lib/follow-up-engine/send-approved-follow-up.ts:285-302
  • The error taxonomy that exists and is not used herefrontend/src/lib/sales/provider-send-errors.ts:27-53
  • The allow-list gate, defaulting to empty stringsequence-workspace-gate.ts:8-10 and frontend/src/trigger/synced-env.ts:30
  • Retry policy that re-claims and re-sendsfrontend/src/trigger/send-approved-follow-up.ts:12
  • Operator send-now uses the legacy path even for spine rowsapi/follow-ups/approve/route.ts:597 → send-approved-follow-up.ts:88
  • The correct pattern, for comparison — a set-once database claimsupabase/migrations/20260826072000_email_touch_send_safety.sql:106-143

Where I could be wrong

No test covers the unknown-outcome case on the legacy path — the existing test uses a generic error and confirms the revert is intentional. So this behaviour is by design for ordinary failures; it is the ambiguous ones that were not considered.

The fix is genuinely small: pass the key that is already accepted, and check the error type that is already defined.

Critical Finding 05

When bounce rates spike, the system sends a Slack message and keeps going

There is no automatic pause anywhere. Spam complaints are not even subscribed to. The bounce check runs once a day, at eleven at night.

Two related gaps here.

Spam complaints do not reach you at all. The list of events subscribed to from your provider covers replies, sends, bounces, unsubscribes and account changes. Spam reports are not on it. There is code elsewhere that handles a complaint category — but it can never run, because no complaint event ever arrives. Unrecognised events are logged and answered with success.

Bounces arrive, but nothing acts on them. The handler increments counters and files an inbox row. The bounced recipient is never marked. Then a separate daily job checks whether the bounce rate crossed 2%, and if it has, it posts to Slack and applies a tag reading Monitor to the affected mailboxes.

A tag is a label. It changes nothing. Searching the entire alerting module for anything that pauses, stops, disables or deactivates returns two results: a log line, and the text of the Slack message itself, which reads *"Consider pausing affected accounts"*.

The one function in the codebase that can pause a campaign creates a recommendation that requires human approval.

The timing compounds it. The check runs at 23:00, once per day.

Why this matters in plain terms

This is the difference between a bad afternoon and a burned domain. Sending reputation is not linear — it degrades slowly and then collapses, and recovery takes weeks of reduced volume.

Picture a customer importing a stale 40,000-lead list on a Friday evening. Bounce rate is 18% by nine o'clock. Nothing stops. The daily job fires at eleven, posts to Slack and applies a tag. Nobody reads Slack until Monday.

By then that customer's sending domains are damaged, and they will attribute it to your platform — with some justification, because they bought a product that manages sending for them.

An automatic pause at a hard threshold is the single most valuable safety feature you could add to the sending engine, and it is a much smaller piece of work than it sounds: the threshold check already exists and already runs. It just needs to do something.

What I actually looked at

  • Spam-complaint events are not in the subscription listsrc/services/bison_provisioning.py:42-54
  • Unrecognised events return success and are ignoredsrc/webhooks/bison_webhook.py:328-340
  • The bounce handler never marks the bounced recipientsrc/webhooks/bison_account_webhook.py:617-806
  • The threshold response is Slack plus a tag — no pause exists in the modulesrc/workers/bounce_rate_alerter.py:283-310, :416
  • The alert check is wrapped in a bare except that swallows everythingsrc/webhooks/bison_account_webhook.py:792-797
  • Runs once daily at 23:00src/workers/daily_health_check.py:139-143
Critical Finding 06

370MB of fourteen clients' real prospect data is committed in your repository, permanently

Tens of thousands of real people's names, emails, phone numbers and addresses are in the code repository — including two clients you offboarded four months ago. Anyone who has ever cloned it holds a full copy.

The clients/ folder holds 653 files and around 370MB of real production lead data across fourteen named client folders. One client alone accounts for 348MB.

The fields are unambiguous personal data: owner first and last names, email addresses, store and mobile phone numbers, business addresses, Facebook profile URLs, job titles and locations.

Two of those fourteen clients were offboarded four months ago. Their prospect databases are still in your repository, and will remain there permanently regardless of what you delete today, because git keeps everything.

One genuinely important mitigation, which was checked rather than assumed: your .railwayignore excludes clients/, so your production containers do not carry this data. The exposure is people who clone the repository, not your live application. That is a meaningful difference and it lowers the severity from what it first appears.

But the set of people who clone it is growing. It now includes an external contractor added last week, and it will include every developer you hire. None of that leaves an audit trail on the client side.

Why this matters in plain terms

This data does not belong to you. It belongs to your clients, and the people in it never consented to anything — that is the nature of cold outreach data, and it is exactly why it carries obligations.

The practical problem is that you cannot answer the questions a customer is entitled to ask. If someone in that data asks to be deleted, you cannot delete them from git history. If a client asks what happened to their data after offboarding, the honest answer is that it is still in your codebase.

The cleanup is more involved than deleting a folder — removing data from git history requires rewriting it, which affects everyone with a copy. That is a real piece of work, and it is why this is worth starting before the number of copies grows.

The immediate step is smaller: stop new data landing there, and get the offboarded clients' folders out.

What I actually looked at

  • 653 files, ~370MB, across 14 named client foldersclients/ — pinpoint-payments 348MB, ecomail, quick-fox, apg-software, trueclean, kaizen and 8 more
  • Personal-data fields confirmed by inspecting field names onlyowner_first_name, owner_last_name, email, store_phone, business_address, facebook_url, JOB_TITLE, CITY
  • Production containers do not carry it.railwayignore excludes clients/

Where I could be wrong

This is not legal advice and a solicitor should confirm the obligations. What is stated here is factual: what is in the repository, who can reach it, and which capabilities do not exist in the code.

Critical Finding 07

You are generating claims about named businesses' regulatory violations with a language model

Campaign copy references specific FDA enforcement actions and state penalties against named businesses, as a pressure hook. In the current pipeline those sentences are written by a model, not looked up — and the technique has been generalised into your reusable skill library.

The lead records pair named businesses with regulatory enforcement detail — fda_charges, fda_outcome, FDA_VIOLATION_TYPE, fda_inspection_date — and campaign variables named STATE_LAW_HOOK, STATE_PENALTY_DETAIL and FDA_TIMING_PHRASE place them into outreach copy. The resulting message tells a business owner about their own violation and what it might cost them.

Two things make this materially worse than it first looks, and one of them is a correction to an earlier read of the code.

First: these sentences are now generated by a language model. The original pipeline built them with deterministic string matching — a lookup table, effectively. The newer manifest-driven pipeline defines three blocks of type ai-personalise producing exactly those three variables, which resolve to a GPT-4o-mini call. So the sentence asserting a named business committed a specific regulatory offence is written by a model rather than read from a record.

Second: the record it draws on is matched by fuzzy comparison. Records are joined to FDA data by a similarity score with a 0.6 threshold, and the cleaning step has a default fallback. Between a loose match and a generative sentence, there is no remaining guarantee that the violation described actually belongs to the business receiving the email.

Third, and this is the part that changes its nature: the technique is not confined to one client. It has been written up and generalised into your shipped skill library, which means it is a documented, reusable play in the platform you are about to sell to twenty to fifty other companies.

Why this matters in plain terms

Stated plainly, because it deserves it: this is telling a small business owner that you know about their regulatory violation, and what the penalty is, in order to sell them something. Even done accurately, that is a technique some of your future customers will not want their name attached to.

Done inaccurately — a wrong fuzzy match, a model-written sentence — it is an assertion that a named business committed an offence it did not commit, sent in writing, by your platform. That is not a deliverability problem. It is a different category of problem entirely.

As a product decision this is the one I would think hardest about. A single client running this is a client's choice. Shipping it as a reusable capability makes it your product's position, and it will be judged that way — by customers, and by anyone they forward the email to.

At minimum it needs a human approval gate before any copy asserting a regulatory violation is sent, and the fuzzy-match threshold needs to be a decision somebody signed off on rather than a default.

What I actually looked at

  • Three ai-personalise blocks generating the FDA variables via GPT-4o-minimanifest blocks personalise-fda_charges, personalise-state_penalty_detail, personalise-fda_timing_phrase → ai_personalise.py::personalise_row
  • The original deterministic cleaner, still present for the older pipelinebison_export.py::clean_fda_charges
  • The technique generalised into the reusable skill libraryshipped skills, not a single client folder
  • Campaign variables placing violation detail into copySTATE_LAW_HOOK, STATE_PENALTY_DETAIL, FDA_TIMING_PHRASE

Where I could be wrong

One thing could not be established: whether the raw FDA charge text is fed *into* the model prompt, or only produced as its output. The recovered configuration snapshot was empty for those blocks. It is answerable from a live campaign config and worth answering.

Credit where due — there is outcome filtering on the FDA records, so this is not indiscriminate. And open-tracking is disabled program-wide, which is a deliberate privacy-respecting choice most competitors do not make.

High Finding 08

On one table, a customer can change another customer's records

ai_recommendations allows any logged-in user to update any row belonging to anyone. Unlike the previous finding, the tenant column here already exists — it simply was not used.

The rule on this table is USING (true) WITH CHECK (true) for updates. In plain terms: any logged-in person may change any row, and may change it to anything.

What makes this worse than the read problem is that the table does have a workspace_id column. The information needed to write a correct rule was sitting right there. This was not a structural impossibility, it was an omission.

The columns involved are not cosmetic. Alongside status there is an action_payload field, and the table carries executed_at, executed_by and execution_result. Those names describe something that gets acted upon.

Why this matters in plain terms

Reading someone's data is a breach. Silently altering it is worse, because the affected customer never learns anything went wrong — they simply act on a recommendation that somebody else wrote.

Nothing in the record distinguishes a tampered edit from a legitimate one, so there is no way to investigate a complaint about it after the fact.

What I actually looked at

  • Update rule permitting any authenticated user to write any rowsupabase/migrations/20260801093210_production_baseline.sql:18126,18130
  • Table has a workspace_id column that the rule ignoressame file — ai_recommendations, 25-column definition

Where I could be wrong

Same caveat as the previous finding: this is read from migration files, not from your live database. Confirm with pg_policies first.

High Finding 09

Your billing code queries a table that no migration creates

Eight tables are queried by running code but are created by none of your 75 live migrations. One of them is subscriptions. Whichever explanation is true, it is worth knowing today.

The eight are teams, team_members, team_invitations, team_member_permissions, subscriptions, google_accounts, campaign_analytics and sales_calls. They appear only in the abandoned migrations/ folder, and are absent from the production snapshot your current migration history is built on.

Meanwhile live code queries them for real — src/routers/billing/subscribe.py and src/routers/billing/webhooks.py against subscriptions, src/routers/teams/members.py against the team tables, and nine separate calls in src/database_google_accounts.py.

One line in that last file is the most revealing thing in the whole repository:

logger.warning(f"Error fetching google_accounts (table may not exist): {e}")

The application does not know whether its own table exists, and has been written to carry on regardless.

Why this matters in plain terms

There are only two possibilities and they need opposite responses.

  • If the tables do exist in production, they were created by hand and never recorded. That means your migration history is not a faithful description of your database — so every test environment rebuilt from it is missing schema that production has, and you are testing against a different database than the one you deploy to.
  • If they do not exist, then team management, Gmail account linking, and billing subscriptions are dead paths that catch their own errors and return nothing. Billing quietly answering "no subscription" is a revenue problem that no alarm will ever catch, because nothing crashes.

You do not need to guess. One query answers it, and it takes a minute.

What I actually looked at

  • Eight tables absent from all 75 live migration files and from the production baselinegrep across supabase/migrations/
  • Live billing code querying one of themsrc/routers/billing/subscribe.py, src/routers/billing/webhooks.py
  • The application logging that its own table may not existsrc/database_google_accounts.py:50

Where I could be wrong

Run this and the ambiguity disappears: select tablename from pg_tables where tablename in ('teams','team_members','team_invitations','team_member_permissions','subscriptions','google_accounts','campaign_analytics','sales_calls');

High Finding 10

For twenty-nine days, every workspace's API keys were readable by any logged-in user

The rule protecting your credentials table checked only that a workspace was active, not who was asking. It was corrected on 30 August. Fixing the rule does not un-disclose the keys.

workspace_config has 109 columns, around 35 of which hold credentials for other services — Bison, GoHighLevel, OpenAI, Anthropic, Slack, Telnyx, Calendly, HubSpot, Apollo, Apify, Instantly and more.

The rule shipped in the August baseline read USING (is_active = true). That asks "is this workspace switched on?" — not "does this person belong to it?". Any logged-in user could read every active workspace's row.

It was fixed on 30 August by a migration that replaced the rule with a proper membership check. The fix is correct and I have confirmed the old rule is gone.

The exposure window was 1 to 30 August at minimum. It is likely longer, because the August baseline was a snapshot of a rule that already existed.

Why this matters in plain terms

These are not your keys. They are your customers' keys to *their* other services. A leaked Slack bot token or GoHighLevel key affects their business, not just yours.

A corrected rule protects the future. It does nothing about anything already read during the window. The only remedy for a possibly-disclosed credential is to replace it.

What I actually looked at

  • The permissive rule as originally shippedsupabase/migrations/20260801093210_production_baseline.sql — workspace_config_frontend_read
  • The corrective migration, dated 30 Augustsupabase/migrations/20260830120000_secure_workspace_perplexity_key.sql

Where I could be wrong

This may be much smaller than it looks. The correcting migration notes that values are Fernet-encrypted and that plaintext is never stored. If all 35 columns held encrypted values throughout, the practical exposure is close to nil.

I could not verify which columns were encrypted during the window and which were not. That is the question to answer before deciding whether to rotate anything — and it is worth answering properly rather than rotating 35 credentials in a panic.

High Finding 11

A bot merges your highest-risk changes, including database migrations, with no human involved

The auto-merge classifier sorts changes into risk tiers and then approves every tier for automatic merging. A database migration went in unreviewed in sixteen minutes.

You have a script that classifies each proposed change by risk, based on which files it touches. The classification itself is well built — deterministic, based on file paths, read from the trusted branch rather than the change itself. That is the right design.

The problem is what it does with the answer. Run across all 576 combinations of file paths, the classifier returns "merge automatically: yes" in every single one. Tier 3 — your highest-risk category — merges automatically just like tier 0.

The tier label is applied, and then ignored. There is a step designed to escalate to a human, but the condition that triggers it can never be true, so it is unreachable code.

In your actual history: across 93 merged changes, a human was requested zero times. 36 were tier 3. Pull request #1005 — a database migration — was merged by the bot 16 minutes after it was opened, with no reviews. #952 merged 1.4 minutes after creation. 70% of changes close within 15 minutes.

Why this matters in plain terms

Speed is not the problem. A solo founder shipping fast is correct, and reviewing your own work is theatre anyway.

The problem is what happens the moment you are not solo. You are about to bring developers in. On day one they inherit a pipeline where anything they write merges to the default branch in about twelve minutes, unreviewed, and — for background jobs — deploys itself to production.

Combined with the previous finding, the picture is complete: the gate says everything passed without looking, and the bot believes it.

What I actually looked at

  • Every risk tier returns automerge=true, across 576 path combinationspr_tier_classifier.py — executed by the reviewer
  • The human-escalation step's condition can never be truepr-tier-automerge.yml — if: steps.classify.outputs.needs_human == 'true'
  • A database migration merged by bot in 16.2 minutes, no reviewsPR #1005, labels tier:t3 + test:migration-risk
  • Zero human reviews across 93 merged pull requestsGitHub check-run history
  • Background-job changes deploy straight to production on mergetrigger-deploy.yml — npx trigger.dev deploy --env prod
High Finding 12

Your test router skips the deepest tests on exactly the changes that most need them

Changes to the live sending path, to database migrations, and to security policies are all marked as not requiring end-to-end tests. Adding risk to a change can remove its tests.

You have a router that decides which tests to run based on what a change touches. Sensible idea. But three of the four highest-risk categories are configured with end-to-end testing switched off:

Change categoryDeep tests run?
Database migrationNo
Login / session securityYes — but only a speed test
Live sending pathNo
Security policyNo
General UIYes

Because the router stops at the first category that matches, and the risky ones are checked first, there is a perverse effect: **a change that touches the send path *and* the sales screens is classified as live-send risk, and therefore loses the routine test it would have had if it were less risky.** Adding danger removes testing.

This is not hypothetical — seven changes to the live sending path have already merged with no end-to-end tests at all.

And the one send-related test that does exist cannot see a send. The test environment sets a flag that makes the code return early, before it ever schedules the send. So no send is scheduled, and the cancellation test's central line — "if there is no scheduled send, skip" — is always taken. The test proves that a row gets *marked* as cancelled. It cannot prove a send was *stopped*, because there was never a send.

Overall, 12 of the last 100 test runs actually ran any browser tests. The other 88 checked out the code, printed a message, and reported green.

Why this matters in plain terms

A green tick that means nothing is worse than no tick, because it stops anybody looking. Eighty-eight percent of your end-to-end results are that.

For a cold-email product, the send path is the product. A silent regression there is not a bug report — it is customers whose campaigns did not go out, or went out twice, discovered days later.

The tests themselves are good. This is not a quality problem, it is a wiring problem, which is much cheaper to fix.

What I actually looked at

  • Three high-risk profiles configured with requires_e2e=Falsepr_test_router.py — migration-risk, live-send-risk, policy-risk
  • Seven live-send changes merged with no end-to-end testsmerged PR history
  • The flag that stops the send from ever being scheduledpr-routed-e2e.yml:23 → frontend/src/lib/auto-reply/finalize-v2.ts:484
  • 12 of 100 runs executed Playwright; 88 skipped every meaningful stepstep-level census across 100 workflow runs
  • The safety check passes if any unrelated test file is includedt3_risk_check.py — satisfied by adding components/unrelated-button.test.ts

Where I could be wrong

Worth stating plainly: the tests you do run are genuinely good. All 636 frontend test files run on every change, the 515 Python tests that run contain zero skipped and zero empty tests, and the cancellation test drives a real browser against a real database with no fakes.

The engineering is sound. It is the routing around it that is hollow — and that is a far better problem to have.

High Finding 13

Your post-deploy check failed 95% of the time for a month, and its alarm could not fire

The workflow that verifies a deployment actually worked has been failing since mid-August. It is supposed to raise an issue when that happens. It cannot, because the labels it uses do not exist.

After each deployment a smoke check runs against the live site. Over the sampled window it failed 58 times out of 61 — every completed run between 13 August and 31 August failed, with the first green in four weeks landing on 10 September.

The failure is always the same shape: a version endpoint poll fails, which means the actual health assertion is never reached.

When it fails, the workflow is meant to open an issue so somebody notices. That step runs a command tagging the issue with two labels — and neither label exists in your repository, so the command errors. There are zero such issues. The alarm has never once made a sound.

One clarifying detail, and it is reassuring: every failure on your default branch in this window was *after* merge — the smoke check and one deploy. Your main CI run is 20 for 20 green. Your branch is not red because bad code slips past the tests. It is red because the one workflow that checks the deployed result was broken, and nothing depends on it.

Why this matters in plain terms

This is the cheapest fix on the entire page — create two labels — and it converts a permanently ignored red light back into a signal.

It also matters more than it looks. A post-deploy check is the only thing that tells you the thing you shipped actually works in production, as opposed to compiling. With auto-merge and auto-deploy both live, it is the last line.

What I actually looked at

  • 58 failures in 61 runs; every completed run failed 13–31 Augustpost-deploy-smoke.yml run history
  • The alerting step uses two labels that do not exist among the repo's 90post-deploy-smoke.yml — gh issue create --label "adw,smoke-failure"
  • Zero issues have ever been created by itrepository issue history
High Finding 14

Two live Google credentials are committed in your repository, and git never forgets

Not one, but two. Different Google Cloud projects, both with a working secret, both permanently in the repository's history — including in every copy anyone has ever taken.

A credential is any string that grants power. These two grant the ability to act as your Google applications.

FileGoogle projectStatus
credentials.json (repo root)inreachly-os-claude-codeLive secret, committed
public/credentials.jsonlead-gen-jay-480915Live secret, committed — a *different* project

Your .gitignore lists credentials.json. That is why this is easy to miss and worth explaining: adding a file to .gitignore stops it being added in future. It does nothing at all about a file already tracked. Somebody added the ignore rule believing it fixed the problem. It never did, and no warning was ever shown.

The second one is in a folder called public/, which is alarming at a glance. It was checked: that folder is not served to the internet — the Python app uses a different static directory, and the Next.js app serves only its own frontend/public/. So the exposure is "anyone with access to this repository", not "anyone with a browser". That is a meaningful difference and it is worth knowing before anyone panics.

Both are desktop-application credentials, which Google itself treats as less confidential than server credentials. That lowers the severity. It does not make it fine.

Why this matters in plain terms

The reason this is rated Critical is timing, not mechanism. You have just granted repository access to an external contractor, and you are about to bring developers in. Every one of them takes a full permanent copy of your history the moment they clone.

Deleting the file now does not help. The value stays in the history, retrievable by anyone with the repository, forever. The only fix that works is replacing the credentials.

What I actually looked at

  • Root credential — Google project inreachly-os-claude-code, includes a live client_secretcredentials.json (413 bytes, committed)
  • Second credential — different Google project, different live secretpublic/credentials.json
  • Verified neither folder is served to anonymous internet visitorssrc/web_server.py static config; Next.js serves frontend/public only
  • The ignore rule that was assumed to be protecting it.gitignore:5 — credentials.json

Where I could be wrong

Both are installed-type (desktop) OAuth clients with localhost redirect URIs, which limits what an attacker can do with them — the realistic abuse is a consent screen carrying your project's name, not direct access to your data.

I would still rotate both. The cost is minutes and the alternative is an unbounded, unmonitorable window.

High Finding 15

Three endpoints act on whatever record you name, without checking it is yours

Most of your endpoints get this exactly right. Three do not — and on one of them, a customer can change another customer's data.

I want to start with what is right, because it is most of it. The strong pattern in your code takes the workspace the caller claims, looks up whether that person actually belongs to it, and refuses with a 403 if not. Twenty-two endpoints do this correctly. Your login layer also rejects unauthenticated calls before they reach any endpoint at all. This is a well-built system and the exceptions are exceptions.

Three endpoints break the pattern in the same way. Each one authenticates the caller, then uses an administrative database connection that ignores all row-level rules, then fetches or changes a record by an id the caller supplied — without ever checking who owns it.

EndpointWhat a customer gets
sales/auto-send/decisionReads another customer's auto-reply decision
follow-ups/preview-timesReads another customer's outbound email body and lead location
recommendations/dismissChanges another customer's records — dismisses their pending recommendations

The second is the most commercially sensitive: it returns the actual text of a follow-up email plus who it is aimed at. That is a competitor reading your customer's outreach copy and target list.

The third is the most serious in kind, because it is a write. One customer can silently degrade another customer's product experience, and nothing in the record shows it happened.

Why this matters in plain terms

An important correction to my own earlier read of this: I first estimated around ten endpoints had this problem, based on a pattern search. Read properly, most of those are safe by a different mechanism — they use the restricted database connection and take the workspace from the record itself rather than from the caller. The real number is three.

I am flagging that because the difference matters for how you respond. Three specific endpoints, each needing the workspace check your codebase already has a helper for, is an afternoon. Ten would have been a systemic problem needing a different answer.

What I actually looked at

  • Reads a record by caller-supplied id with no ownership checkfrontend/src/app/api/sales/auto-send/decision/route.ts:26-34
  • Returns another tenant's email body and lead locationfrontend/src/app/api/follow-ups/preview-times/route.ts — loadPreviewRow
  • Writes to another tenant's recordsfrontend/src/app/api/recommendations/dismiss/route.ts:714-727
  • The correct pattern, already in your codebasefrontend/src/app/api/workspace/api-keys/route.ts:75-84

Where I could be wrong

This is harder to exploit than it sounds, and I want to be accurate about that. All three record ids are randomly generated UUIDs — verified in the migrations, not assumed. An attacker cannot simply count upwards through them. They would need to obtain a specific id first, from a screenshot, a shared link, a log, or a support conversation.

That is why this is rated High rather than Critical. A random id is a real obstacle. It is not an access control, and it should not be the only thing standing there.

High Finding 16

You are running two complete backends at once, and neither one is scheduled to stop

The migration in your architecture document is not a plan — it is half-finished and both halves are live. That is the most expensive state a system can be in, and nothing currently defines when it ends.

Your architecture document proposes adopting Next.js, TypeScript, Trigger.dev and Mastra. All four are already installed and running. What it does not describe clearly is that the system they were meant to replace is also still running.

Python systemTypeScript system
What your Procfile startsThis one
Web endpoints25 router files80 API routes
Scheduled work31 Railway cron jobs24 Trigger.dev tasks
Incoming webhooksBison, Clay, GoHighLevel, InstantlyA separate webhook tree
Admin interface14 web_server_*.py filesThe Next.js dashboard
Billingsrc/routers/billing/Stripe in the frontend

Your document describes the Python side as "some important working functionality" to be kept "behind clear boundaries". That undersells it considerably. It is a second application with its own scheduler, its own webhooks, its own admin pages and its own billing routes.

And there is no boundary. A boundary would mean one side owns a set of tables and the other asks it for things. Instead both write the same tables directly — the follow-up queue is referenced 7 times in the Python code and 92 times in the TypeScript. Two codebases, two languages, two schedulers, one set of rows, no owner.

The six reconcilers are the tell. A reconciler is a job that exists purely to check whether reality matches what the database believes, and repair it when it does not. You have six, split across both stacks. Nobody writes six reconcilers by choice. They are the system telling you, in its own handwriting, exactly where it does not trust itself.

Why this matters in plain terms

The strangler approach you named is right, and I would not change it. But a strangler only works if the old thing eventually dies. Yours is not dying, because nothing says when it should — there is no list of what remains, no owner per item, and no test that fails when new work is added to the old system.

Left alone, this stops being a migration and becomes your architecture. You then pay for two of everything permanently: two deployments, two sets of dependencies, two places to fix every bug, and a new developer who has to learn both before they can safely change either.

The single highest-value document you could write this month is the kill list. Every Python route and cron job, marked port / keep / delete, with a date. It costs a morning and it converts an open-ended cost into a finite project.

What I actually looked at

  • The Python service is what actually bootsProcfile — uvicorn src.mcp_remote_server:app
  • 31 Python cron jobs alongside 24 Trigger.dev taskssrc/run_*.py and frontend/src/trigger/
  • Both stacks write the same tablefollow_up_queue — 7 references in src/, 92 in frontend/
  • Six reconcilers across the two systemssrc/run_reconciliation.py + 5 *-reconciler.ts in frontend/src/trigger/
High Finding 17

One person holds the whole system, and the guide for new developers describes a different product

4,484 of 5,129 commits are yours. The onboarding document a new developer would read first describes software this repository no longer contains.

The commit history has exactly one human in it. The only other contributor is an automated error-reporting bot. That is normal for a founder-built product and it is not a criticism — but it is the single largest risk to the business, and it is the one you are actively trying to solve by hiring.

The problem is what a new developer finds when they arrive. Your README.md describes Inreachly OS as *"a multi-tenant MCP platform connecting Claude Desktop to Gmail, Calendar, Docs, Slack — 106 tools across 10 platforms"*. Your DEVELOPER-HANDOFF.md states the backend is FastAPI and Uvicorn.

Neither describes the product you are selling. The cold-email platform, the Next.js application, Trigger.dev, Mastra, the sending engine — none of it appears in either document. A developer reads these on day one and builds an entirely wrong mental model, then spends their first fortnight discovering it is wrong.

There is a second, subtler version of the same problem. The repository contains around 1,827 files across thirteen different AI-assistant configuration folders, and 1,197 date-stamped planning documents — docs/superpowers/ alone holds 1,024 files. These are the by-products of building with AI assistance, committed and never cleared.

That produces an unusual kind of knowledge debt. With ordinary legacy code, the reasoning is at least reconstructable from the code. Here much of the reasoning lived in AI conversations that no longer exist, and what was written down is a thousand superseded plans with nothing marking which ones still apply.

Why this matters in plain terms

This is why hiring will feel slower and more expensive than the codebase size suggests. It is not the volume of code — it is that there is no reliable map, and the map that exists points at a different country.

It also compounds every other finding on this page. A new developer cannot be expected to notice that the merge gate checks nothing, or that three endpoints skip an ownership check, when the document explaining the architecture describes different software entirely.

Rewriting those two documents is perhaps a day of work and it is the highest-leverage day available to you before anyone joins.

What I actually looked at

  • 4,484 of 5,129 commits by one person; the only other committer is Sentry's botGitHub contributors API
  • README describes an MCP tools platform, not a cold-email productREADME.md
  • Handoff guide names FastAPI + Uvicorn as the backendDEVELOPER-HANDOFF.md §1
  • 1,827 files across 13 AI-toolchain folders; 1,197 date-stamped planning docs.agents/ .claude/ .codex/ .kiro/ .superpowers/ _bmad-output/ docs/superpowers/

Where I could be wrong

Some of that documentation volume is genuinely valuable — the docs/runbooks/ and docs/audits/ folders contain real operational knowledge. The problem is that nothing distinguishes current from superseded, so the good material is diluted by an order of magnitude.

High Finding 18

One customer's big import can hold up everyone else's sending

You asked about this exact scenario on the call. It can happen — and the same twenty-slot limit is also why a bad hour at your email provider can freeze sending for every customer at once.

Picture a shop with twenty tills. Every customer queues for the same twenty. Normally that's plenty — until one customer wheels in five hundred trolleys. They're not doing anything wrong, and the tills aren't broken. But every other customer now waits behind them.

That's your sending system. Each of your four sending tasks allows twenty jobs at once, on a queue shared by the whole platform. One job sends one email — the code confirms it, each job handles a single follow-up rather than a batch.

Trigger.dev can give each customer their own till. The setting is called a concurrency key, and it appears zero times in your codebase.

You do attach the workspace to every job, but as a *tag*. A tag is a name badge on the trolley: handy for finding things afterwards, no help at all in the queue.

### Twenty at once is not twenty per minute

Worth being precise, because it's the number you'll want to test against. Twenty is a limit on jobs running at the same moment, not a rate. You could queue fifty thousand emails in one second — twenty send, the rest wait. A slot frees the moment a job finishes.

Which means your actual sending speed is not something anyone configured. It falls out of how long each send takes:

If one send takesYou get roughlyIn shop terms
2 seconds (provider healthy)~600 emails/minuteTills flying
10 seconds (provider busy)~120 emails/minuteQueue building
60 seconds (provider struggling)~20 emails/minuteEveryone waiting

So your capacity is set by your email provider's response time, and nobody chose it. When they slow down, your throughput drops by the same proportion, for every customer simultaneously, and nothing in your dashboard says so.

### The part that turns slow into stuck

Now the compounding problem, and it's the reason this matters more than a queueing inconvenience.

Your provider client has no timeout. None of the calls set one, and the underlying tool doesn't add one by default. Your job platform will eventually kill a run, but not for five minutes.

Back to the shop: the card machine at a till hangs. The till isn't closed — there's still a customer standing at it, going nowhere, for five minutes. Multiply by twenty and the shop has twenty tills and zero throughput.

That's not hypothetical, because these failures arrive together. A provider incident doesn't hang one call, it hangs everything in flight. Twenty stalled calls and that task is frozen platform-wide for five minutes.

And it doesn't end cleanly. When the platform finally kills those runs, they die mid-flight — after the send may have gone out, before anything was recorded. Those rows sit in sending forever, and the job that finds them counts them and moves on.

So one hung connection produces three separate symptoms you'd otherwise investigate separately: sending stalls for everyone, some customers' sequences stop dead, and nobody can tell whether those emails went out.

### Where the chain breaks

All of it starts at the same missing line:

no timeout  →  slots held for 5 min  →  queue starves for everyone  →  runs killed mid-send  →  rows stuck forever

Set a fifteen-second timeout on the provider calls and the chain breaks at the first link. A slow call fails fast, frees its till, and retries — instead of holding a slot hostage and stranding a record.

The pattern already exists in your own codebase — bison-active-accounts.ts sets exactly this. It just wasn't applied to the sending path.

Why this matters in plain terms

Your customer sees no error. Their follow-ups just turn up late, sometimes overnight in the prospect's timezone, and nothing explains why. They'll read it as the product being flaky.

It gets worse precisely as you succeed. Twenty slots across five customers is generous; across fifty it's a bottleneck. The Kaizen cohort arriving together is the event that finds it.

Two fixes, both small, and they solve different halves. The concurrency key stops customers blocking each other — one line on four tasks. The timeout stops a provider wobble freezing the platform — one line per call. Between them they're the best effort-to-risk trade on this page.

Something concrete to test before launch: run your largest realistic import from three workspaces at once, and check whether a fourth workspace's follow-ups still leave on time. Then cut the provider connection mid-send and confirm nothing double-sends and nothing gets permanently stuck. Today, both of those fail.

What I actually looked at

  • No concurrency key anywhere — every customer shares one queue per tasksearched all 287 files in frontend/src/trigger and frontend/src/mastra
  • Four sending tasks, each capped at 20 simultaneous jobs platform-widetrigger/send-approved-follow-up.ts:13, send-follow-up.ts:63, touch-execute.ts:8, auto-send-reply.ts:59
  • One job sends one email — payload is a single record id, not a batchsend-approved-follow-up.ts — payload.followUpQueueId
  • Workspace passed as a tag, which does not affect schedulingall 9 job dispatch sites
  • No timeout on any provider call in the TypeScript clientfrontend/src/trigger/utils/bison-client.ts — 370 lines, no AbortSignal
  • Runs are killed after five minutesfrontend/trigger.config.ts — maxDuration: 300
  • The correct pattern, already in your codebasefrontend/src/lib/server/bison-active-accounts.ts:28 — AbortSignal.timeout(15000)
  • Send window set at planning time, never re-checked before sendingsequence/send-window.ts — no callers in any send task

Where I could be wrong

One number to check that I could not: Trigger.dev plans carry an account-wide concurrency ceiling above these per-task limits. If yours is lower than 80, the four queues compete for it and your real limit is tighter than this finding assumes. It is one screen in your Trigger.dev dashboard.

The throughput figures above are arithmetic from the twenty-slot limit, not measurements. Your real numbers need your provider's actual response times — which is worth graphing, since nothing currently reports it.

High Finding 19

Every weekend follow-up, for every customer, is scheduled for the same instant

Follow-ups landing on a Saturday or Sunday are all moved to Monday at exactly 13:00 UTC — the same millisecond — with no randomisation anywhere.

When a follow-up would fall on a weekend, the scheduler moves it to the next weekday and sets the time to nine in the morning, US Eastern. It does this by setting the hour, minute, second and millisecond to fixed values.

The result is that every weekend-deferred follow-up, across every lead and every customer, carries an identical timestamp.

There is no jitter — no small random offset to spread sends out. The function that would generate one appears zero times across the entire background-job codebase.

Two related gaps sit alongside it. Sending windows are computed when a follow-up is planned and never checked again before it goes out, so anything delayed sends outside its window. And the daily send caps that exist in your configuration are read by nothing at all — the enforcement relies entirely on your provider.

One more detail worth knowing: this path hardcodes US Eastern time rather than using the prospect's timezone, even though timezone handling exists elsewhere in the system.

Why this matters in plain terms

Receiving mail systems judge sending patterns, not just content. A burst of identically-timed messages from the same domains is one of the clearer machine-detectable signals of automation.

The visible symptom is that Monday inbox placement is measurably worse than other days, with nothing in the product to explain it — and it gets worse as you add customers, because they all pile into the same instant.

Adding a few minutes of randomisation is a small change with a direct effect on the thing your customers are buying.

What I actually looked at

  • Weekend sends collapsed onto a fixed hour, minute, second and millisecondfrontend/src/lib/follow-up-timing.ts:33-44
  • No randomisation in any background jobMath.random absent from frontend/src/trigger and frontend/src/mastra
  • Daily send caps declared in config and read by no codesequence/channel-config.ts:92-93
  • Send window never re-checked before the provider callno references in any send task
High Finding 20

When a send outcome is unknown, nothing ever resolves it

The system correctly refuses to guess when it does not know whether an email went out. It then has no way to find out, and no way for a person to decide.

This is a case where the careful half was built and the follow-through was not.

When the outcome of a send is genuinely ambiguous, the newer path records that fact and stops. It will not retry, because retrying might duplicate. That is the right call.

But nothing resolves the record afterwards. The reconciler that examines these simply counts them. There is no check against your provider to ask what actually happened — and the database has a field for exactly that, with three permitted values: the provider's response, an authoritative lookup, or an operator confirming manually. Two of those three are never used anywhere in the code. Only the provider's own response is ever recorded.

The operator interface is read-only about it. It displays: *"remains unresolved and will not be resent"* and *"Provider confirmation is still required; automatic resend is blocked"*. There is no button.

A related version affects the older path: records that get stuck mid-send are found by a reconciler every ten minutes, counted, and left alone. The classification is literally named "needs audit". Combined with the absence of any timeout on provider calls, a hung connection leaves a record stuck permanently and that lead's sequence stops dead.

Why this matters in plain terms

The customer-visible symptom is the worst kind: silence. Leads stop progressing mid-sequence with no error, no alert and nothing in the queue. Nobody notices until someone asks why a prospect went quiet.

It is also unbounded. Every provider outage adds more permanently stuck records, and none of them ever clear.

What I actually looked at

  • The reconciler counts unresolved sends and takes no actionfrontend/src/mastra/sequence/sequence-touch-recovery.ts:174-177
  • Two of three resolution methods are never used in production codeoutbound_reply_send_operations.resolution_source — only provider_response is ever written
  • The operator interface offers no way to resolve onecomponents/lead/tabs/sequence-incident-history.tsx:49-54
  • Stuck records are found, counted, and not repairedtrigger/follow-up-lifecycle-reconciler.ts:48-65
  • No timeout on any provider call in the TypeScript clientfrontend/src/trigger/utils/bison-client.ts — 370 lines, no AbortSignal

Where I could be wrong

Worth crediting: the decision not to auto-retry an ambiguous send is correct and deliberately built, with the guard enforced in the database rather than only in code. This finding is about the missing second half, not a flaw in the first.

High Finding 21

Prospect names, emails and entire conversations are sent to two US AI vendors, undocumented

There is no data-processing agreement, no sub-processor list and no filtering at the point where personal data enters a model prompt. Your own internal audit flagged this in April as blocking launch.

Personal data reaches OpenAI and Anthropic across at least six distinct places in the code:

  • Every inbound reply goes to both vendors. The classifier sends it to OpenAI; a second quality-judge step then sends the same text to Anthropic. One prospect email, two vendors, per reply cycle.
  • Reply drafting sends the entire thread plus identity — first name, company, job title, industry, location, timezone, website, and every message body verbatim, including both parties' email addresses.
  • Owner identification sends named individuals to OpenAI. Text scraped from state corporate registries, Yelp, the Better Business Bureau and LinkedIn is sent to a model to extract *"the owner, founder, or principal's name"* and their phone number — for the purpose of cold-calling them.
  • There is no filtering at the boundary. The prompt builder concatenates whatever columns a campaign config names, with no allow-list and no personal-data filter. Today's configs happen to be restrained; a config change is all that stands between that and sending email addresses and phone numbers.

Nowhere in the repository is there a data-processing agreement, a sub-processor list, a privacy policy, or a zero-retention configuration with either vendor.

Your own team already found this. An internal audit note from April names the required sub-processor list and records it as blocking a Phase 2 launch. It has not been done. And the draft sub-processor list that does exist omits OpenAI — the vendor on the majority of these paths.

One smaller item in the same family: one email-verification vendor is called with the address in the URL query string rather than the request body. Query strings get written to access logs at every hop between you and them. Two comparable vendors in your own codebase do it correctly by POST body, so this is a one-line fix.

Why this matters in plain terms

When you sell this to other companies, you become a data processor acting on their behalf, and they become responsible for who you pass their data to. They will ask for a sub-processor list. Several will ask before signing.

Right now you could not produce an accurate one, because nobody has mapped these flows — which is what makes this a launch blocker rather than a paperwork task. Your April audit reached the same conclusion.

There is a genuinely good control here worth preserving and advertising. Where a customer supplies their own AI vendor key, the system uses it — so their data goes to their own vendor account, under their own agreement. That is a better design than a shared platform key and it materially improves your position. It should be documented, and it should be verified that every path uses it rather than falling back to a platform key.

What I actually looked at

  • Inbound replies sent to a second vendor after the first classificationqa-judge.ts (claude-sonnet-4-6) following reply-classifier.ts (OpenAI)
  • Full thread and identity fields sent to Anthropicgenerate-initial-reply.ts::renderLeadBlock / renderThreadBlock; reply-writer/prompt.ts:29-30,83
  • Named individuals from registry data sent to OpenAIfind_owner.py — two gpt-4o-mini prompts
  • No allow-list or filter at the prompt boundaryprompt_builder.py::build_user_prompt
  • Your own audit recording the missing sub-processor list as blocking.cache/spec-qa-missing-pieces.md §H.2, dated 2026-04-28
  • Email address placed in a URL query stringreoon_verify.py:88
  • The good control — per-tenant vendor keysgetTenantSecret(workspaceId, "anthropic"|"openai")

Where I could be wrong

Not legal advice — a solicitor must confirm what is required. What is stated here is what the code does and which documents do not exist.

It could not be established whether every path uses the customer's own vendor key or whether some fall back to a platform key. That distinction matters a great deal for who is contracting with whom, and it is worth resolving.

High Finding 22

Your customer-separation check runs once per row, and does two database lookups each time

Someone on your team knew the optimisation that avoids this — it is applied to one part of the rule and not the part that costs the most. Invisible today; it arrives as a cliff, not a slope.

Your main separation rule has four tests, joined by "or". They behave very differently:

The testHow often it runs
Is this an admin? (from the login token)Once per query — correctly optimised
Is this an admin? (from the profiles table)Once per query — safe, because of how it is written
Does this user have access to this row's workspace?Once per row
Does the row's workspace match the token?Once per query

The third one is the problem, and it is structural rather than careless. It takes the row's own workspace as an input, so the database cannot work it out in advance — it has to ask again for every row it considers.

Two details make it heavier than it looks. It is written in a language the database cannot fold into the surrounding query, so each call carries real overhead. And inside it, it first checks the profiles table, then checks the access table. That is two lookups per row.

The good news, and it is genuinely encouraging: the first test *is* wrapped in the pattern that makes it run once. Somebody knew about this. It just was not applied to the expensive branch — the one that takes a column and therefore cannot be hoisted.

Why this matters in plain terms

This is invisible at your current size and stays invisible for a long time, which is exactly what makes it dangerous. At ten thousand rows nobody notices. The cost rises with the number of rows a query has to consider.

Where it bites first is not ordinary page loads — those filter to one workspace and stop early. It bites on counts, totals and dashboards, which have to look at every row a customer owns. A customer with 500,000 leads loading an analytics page pays a million lookups for one number.

The standard fix turns it from per-row into per-query: rewrite the test as "is this row's workspace in the list of workspaces this user can see", with the list fetched once. The database can then use an index instead of asking a question per row.

It also becomes much harder to change later, because the rule is attached to many tables. Doing it now is a contained piece of work.

What I actually looked at

  • The four-branch rule, with only the first wrapped for single evaluationsupabase/migrations/20260803152119_...sql:44 — tenant_isolation policy
  • The per-row function takes the row's workspace as an argumentuser_has_workspace_access(workspace_id)
  • Written in plpgsql, which the planner cannot inline20260305000000_fix_rls_infinite_recursion.sql:74-99
  • It queries profiles, then queries the access table — two lookups per callsame function body
  • The correctly-optimised branch, showing the pattern is known(SELECT (auth.jwt() ->> 'user_role')) = 'admin'

Where I could be wrong

I cannot measure this without database access, and the real cost depends entirely on row counts I cannot see. The mechanism is certain; the magnitude is not.

Run explain analyze on your heaviest tenant-scoped query — a lead list and an analytics total — and look for the function appearing in the row-level filter. That measurement takes ten minutes and turns this from a prediction into a number.

High Finding 23

You are using the connection mode that does not share connections, across a lot of separate processes

Supabase offers two pooling modes. You are on the one where each process holds its own database connection for as long as it runs — and you have a lot of processes.

A database can only hold so many connections open at once. It is a hard ceiling, and when you hit it, everything fails at the same moment rather than getting gradually slower.

Supabase gives you two ways to connect. Transaction mode hands a connection back after each query, so hundreds of clients can share a few dozen connections. Session mode gives each client its own connection and keeps it for the whole session.

Your configuration uses session mode. The comment in your own config explains why — it was chosen for network-compatibility reasons, which is a legitimate reason and not a mistake. But it means your connection count scales with how many processes you run, not how busy they are.

And you run a lot of processes: the Python web service, the Next.js application, a fleet of scheduled jobs — you have 31 separate job scripts — and up to 20 simultaneous background runs per sending task.

Idle processes still hold their connections in this mode. A scheduled job that runs for thirty seconds every five minutes may hold a connection the whole time.

Why this matters in plain terms

This is a cliff, not a slope. Below the ceiling everything is fine. At the ceiling, new connections are refused and every part of the system fails at once — the app, the jobs, the sending. It will not look like a database problem; it will look like a total outage.

It is also the ceiling most likely to arrive first, because it scales with your process count and your background jobs rather than with your customer count. You could hit this at 50 customers or at 500 depending on your plan, and you would not see it coming.

Two things to do: check your plan's connection limit and your current usage — one screen in Supabase, five minutes. Then move the connection string to transaction mode where the code allows it, which is usually a port change plus removing anything that depends on session state.

What I actually looked at

  • Connection string points at the session pooler on port 5432, not the transaction pooler on 6543.env.example — DATABASE_URL, pooler.supabase.com:5432
  • The config's own comment confirms session pooler was a deliberate compatibility choicesame file
  • 31 separate scheduled job scripts, each a processsrc/run_*.py
  • Up to 20 simultaneous background runs per sending taskfour Trigger.dev tasks with concurrencyLimit: 20

Where I could be wrong

I could not measure your actual connection count or see which plan you are on, so I cannot tell you how close to the ceiling you are — only that this is the mechanism and it is the kind that fails all at once.

Transaction mode is not a drop-in swap for everything: it does not support session-level features like prepared statements in some drivers. Worth checking each service rather than changing them all at once.

High Finding 24

You built a credit system that can stop a customer overspending, and it is running in observe mode

The accounting is genuinely well built — reservations, an immutable ledger, whole-number money, idempotent top-ups. Your own runbook confirms the tooling that switches it on cannot set the mode that enforces anything.

Credit first, because this is good work. You have a credit ledger with reservations, immutability rules that block tampering with a paid top-up, idempotency keys so a retry cannot double-charge, and money stored as whole micro-units rather than decimals. There is a reconciliation job for stale reservations. This is better than most billing systems.

The account has a mode setting, and enforce is one of the valid values. The database constraints require an enforcement baseline before that mode can be set, which is careful design.

But your own runbook states plainly that the activation tool "never supports enforce" and defaults to dry-run. The documented mode is observe, which the same document describes as recording usage *"but does not reserve or debit the workspace balance"*.

So the machinery is built, wired and switched off. Today, a customer's spending is measured accurately and capped by nothing.

One related gap: I found no rate limiting on your customer-facing endpoints. Several of those endpoints make AI calls.

Why this matters in plain terms

Every AI call you make on a customer's behalf costs you money the moment they make it, and stops costing you money only when they stop. With no cap, the ceiling on a single customer's spend is their patience.

The realistic version is not malice, it is a mistake — a customer scripts against your API, or uploads a list ten times the size they meant to. An unmetered path behind a public endpoint is a direct line into your bank account.

This is a good position to be in, though. You do not need to build anything. You need to establish a baseline, switch a mode, and decide what happens when a customer hits zero. The hard part is already done — which is why this is worth doing before the cohort arrives rather than after the first surprise invoice.

What I actually looked at

  • The runbook stating the tool never supports enforce and defaults to dry-rundocs/runbooks/workspace-usage-accounting.md:14-16
  • Observe mode does not reserve or debit the balancesame file:4-5
  • Enforcement exists in the schema with proper constraintssupabase/migrations/20260810163000_add_live_credit_enforcement.sql
  • Immutability trigger protecting paid top-upssame file — enforce_workspace_credit_topup_state
  • No rate limiting found on customer-facing API routesfrontend/src/app/api/ — no limiter middleware
Medium Finding 25

Adding a database index can freeze your send queue mid-deploy

725 indexes have been created and not one uses the option that avoids locking the table. Seventy of them are on tables the sending system uses constantly.

An index is the database equivalent of the index at the back of a book — without one, finding a row means reading every row. Adding one is routine.

Postgres offers two ways to add an index. The default takes an exclusive lock on the table and holds it for the whole build, blocking everything else. The alternative, CONCURRENTLY, builds it without blocking, in exchange for taking longer.

Every index in your migration history uses the default. Nine of them are on follow_up_queue — the live send queue — and five on sequence_event_outbox.

A related detail: these are written as CREATE INDEX IF NOT EXISTS. If a build fails, that phrasing lets the migration report success anyway, leaving you believing a uniqueness rule is in place when it is not.

Why this matters in plain terms

On a small table the lock lasts milliseconds and nobody notices. That is precisely why this is invisible now and arrives as a surprise later — the lock duration grows with the table.

Once the queue is large, a routine deploy stops sending for every customer at once, for as long as the build takes. It will not look like a deploy problem. It will look like the product broke.

What I actually looked at

  • 725 indexes parsed across the migration history, none using CONCURRENTLYsupabase/migrations/ — 70 created by post-baseline migrations
  • A representative example on the live send queuesupabase/migrations/20260831123940_add_sequence_active_touch_sending_index.sql

Where I could be wrong

How bad this is depends entirely on how many rows those tables hold, which needs database access to measure. Below a few hundred thousand rows it is a non-event.

Medium Finding 27

The two database functions that enforce customer separation are missing a standard safety setting

No working exploit was found, and the code is written defensively enough that one may not exist. It is worth fixing because of what these two functions do.

Some database functions run with the permissions of whoever created them rather than whoever calls them. That is a normal and useful feature, and it is also a well-known category of risk — such a function should always be pinned to a fixed set of locations when it looks things up, otherwise a malicious actor could in theory substitute their own objects and have them executed with elevated permissions.

Two of your functions are missing that pinning: is_admin() and user_has_workspace_access(). The second is the function referenced inside your main customer-separation rule, so it runs constantly, on many tables.

No exploit was found and that is not a formality. The function bodies name every object they touch explicitly, which defeats the usual attack, and Supabase does not let ordinary logged-in users create the objects that would be needed. This is a hardening gap flagged by Supabase's own linter, not a demonstrated hole.

What makes it worth doing is that your team already knows the fix. Newer functions written later — is_admin_user() and jwt_tenant_id() — do set the pinning correctly. These two are older and were simply never revisited.

Why this matters in plain terms

The severity here comes from position, not probability. These are the functions the whole tenant-separation model rests on. A low-probability weakness in the thing everything else depends on is worth an hour, even when the probability is low.

It is also a one-line change per function.

What I actually looked at

  • Both functions declared without a pinned lookup pathfrontend/supabase/migrations/20260305000000_fix_rls_infinite_recursion.sql:17-25, 74-99
  • The function is used inside the live tenant-separation rulesupabase/migrations/20260803152119_...sql:44
  • Newer functions that do it correctlysupabase/migrations/20260517130000_active_workspace_preferences.sql:32-51

Where I could be wrong

Around 216 other functions of this type were not examined. This finding covers the two that matter most, not all of them.

A full review of the rest needs database access.

Medium Finding 28

The audit already in your repository was written by the same tool that built the thing

audit-report-20260426 opens by stating it was generated by Claude Code, reviewing configuration Claude Code produced. It concluded "accept as-is". That is not an independent check.

There is an audit report sitting in your repository root from April. Its first line reads: *"Generated by Claude Code reading audit-input-20260426-043017.md"*. It reviewed 51 pipeline configurations, found one cosmetic issue, and recommended accepting everything as-is.

The work itself looks competent. That is not the issue. The issue is that the thing being checked and the thing doing the checking were the same system, so agreement between them proves nothing. A tool cannot independently verify its own output — it will tend to find the code correct for the same reasons it wrote it that way.

This matters because that report is now sitting in your repository looking like assurance. Anyone finding it — a developer, an investor, a customer's technical reviewer — would reasonably read it as "this system has been audited".

It is also worth noticing what it audited: block wiring inside lead pipelines. Narrow and mechanical. None of the thirteen findings on this page would have been visible from that vantage point, because it was never looking at them.

Why this matters in plain terms

The practical risk is false confidence — believing a question has been answered when it has not been asked. That is more dangerous than knowing you have not checked.

The fix is not to delete it. It is to label it accurately: what it covered, what it did not, and who produced it. A narrow check honestly labelled is useful. A narrow check mistaken for a broad one is a liability.

What I actually looked at

  • The report states its own authorship in its first lineaudit-report-20260426-043017.md:3
  • Scope was 51 pipeline block manifests; verdict was accept-as-issame file, Summary table
Medium Finding 29

How many customers your sending can actually carry

The arithmetic, so you can substitute your own numbers. The answer is not a customer count — it is a peak-demand number, and your scheduling makes your peaks much worse than your average.

Twenty simultaneous sends per task, one email per send. So capacity depends on how long a send takes:

Send takesEmails/hourEmails in an 8-hour window
2 seconds~36,000~288,000
5 seconds~14,400~115,000
10 seconds~7,200~58,000

At a healthy two seconds, 288,000 emails a day is a lot of customers — on paper. If a customer sends 1,000/day, that is roughly 288 customers before the queue is the limit.

But average capacity is the wrong number, and this is the part that matters. Sending is not spread evenly. It clusters into business hours, and your own scheduler makes the clustering far worse: every follow-up that lands on a weekend is moved to Monday at exactly the same instant, with no randomisation.

So the real question is not "how many emails per day" but "how many want to go out in the same minute". Twenty slots is your answer to that, and it does not grow.

What actually binds first, in order: your peak-minute demand exceeding 20 slots, long before your daily volume approaches capacity. Then your provider's own rate limits, which you do not currently handle. Then database connections.

Why this matters in plain terms

This is the number to test rather than reason about. Take your largest expected cohort, work out how many follow-ups would be due in the same five-minute window, and compare it to twenty.

The fixes already listed change this picture significantly: per-customer queueing stops one tenant consuming everyone's capacity, and adding randomisation spreads the peak. Neither raises the ceiling, but both stop you hitting it unnecessarily.

What I actually looked at

  • 20 simultaneous runs per task, one email per runfour Trigger.dev tasks; payload is a single record id
  • Weekend follow-ups collapsed onto one identical timestampfrontend/src/lib/follow-up-timing.ts:33-44
  • No randomisation anywhere in the background jobsMath.random absent from frontend/src/trigger and frontend/src/mastra

Where I could be wrong

Every number here is arithmetic from the 20-slot limit, not a measurement. Your real send latency is the missing input, and nothing currently records it — which is itself worth fixing, because it is the number that determines your capacity.

There is also an account-wide concurrency limit on Trigger.dev plans sitting above these per-task limits. If yours is below 80, your real ceiling is lower than this. One screen in their dashboard.

The nine questions from your call guide

Answers to your questions

Taking your nine sections in order. I have tried to answer the question you asked rather than the question that is easiest to answer well.

1. Should we improve the existing system in stages, or rebuild?

Agree — improve in stages. But you are further into it than your document admits.

Gradual replacement is right, and a rebuild would be a serious mistake. You have real customers sending real campaigns, and a great deal of hard-won detail lives in that code — deliverability handling, provider quirks, the enrichment chain. A rebuild throws that away and spends six months arriving back where you started, with a new set of bugs.

But your document frames this as a decision you are about to make, and it is a decision you made months ago and have half-executed. The Next.js application, Trigger.dev, Mastra and Sentry are all live. So is a complete second backend in Python, with its own scheduler, its own webhooks and its own admin interface. Both are running right now.

The strangler approach you name is the correct one — but a strangler only works if the old thing eventually dies. Yours is not dying, because nothing defines when it should. There is no list of what remains on the Python side, no owner for retiring each piece, and no test that fails when someone adds new work to the old system.

What I would change: write the kill list. Enumerate every Python route and cron job, mark each as *port*, *keep permanently*, or *delete*, and give the whole list a date. Without that, the two-system state stops being a migration and becomes your architecture — and you will be paying for two of everything indefinitely.

2. Does each tool have a clear job? Are we using too many?

The tool choices are sound. The problem is not the count — it is that jobs are duplicated across two stacks.

Taken individually every choice is defensible. Next.js, Supabase, Trigger.dev and Mastra are a coherent stack that a small team can hold in its head, and none of them is exotic.

The duplication is not between the tools you listed. It is between the tools you listed and the system you already had:

  • Two schedulers. 31 cron jobs on Railway running Python, and 24 Trigger.dev tasks running TypeScript.
  • Two web servers. The Next.js app, plus 14 web_server_*.py files serving their own admin pages.
  • Two webhook receivers. Bison, Clay, GoHighLevel and Instantly arrive at the Python service; a separate /api/webhooks/ tree exists in Next.js.
  • Two billing paths. src/routers/billing/ in Python, and Stripe wired into the frontend.
  • Six reconcilers, split across both.

Is anything missing? Yes, and it is the same thing every time: a single place that owns the rules. Both stacks write the same database tables — the follow-up queue is touched in 7 places in Python and 92 in TypeScript. When two codebases in two languages write the same rows with no shared owner, reconcilers are what you build instead of correctness. That is why you have six.

Manageable for a small team? The named stack, yes. The current reality — two of everything, in two languages, for one developer — no. That is the honest answer.

3. Is the TypeScript-default, keep-Python-where-it-works plan sensible?

The principle is right. The way it is written will let the split last forever.

I agree with your stated view: working Python should not be rewritten merely to make everything one language. Rewrites for tidiness are how small teams lose quarters.

But "keep it behind clear boundaries" is doing a lot of work in that sentence, and the boundary does not currently exist. A boundary means one side owns a set of tables and the other side asks it for things. What you have instead is both sides reaching into the same tables directly. That is not a boundary, it is a shared mutable state with two authors.

How I would decide what moves: not by language preference, but by ownership. Any table written by both stacks is a problem regardless of language. Pick one owner per table, and move whichever side loses.

What should move first: the things that duplicate. Follow-up sweeping and reconciliation exist on both sides — that is where correctness bugs will come from, and where a fix applied to one half silently misses the other.

What should stay: the enrichment and lead-sourcing pipeline. It works, it is genuinely intricate, it is not on the critical path for multi-tenancy, and porting it buys you nothing a customer can see.

4. Is Trigger.dev right for queues? What happens when several customers upload large lists together?

Trigger.dev is the right choice. But the specific thing you are worried about can happen today, and the fix is one line in four places.

Trigger.dev is a good fit and I would keep it. It gives you durable jobs, retries, scheduling and recovery without you running queue infrastructure, and you are already using it properly in places — every one of your nine job dispatches passes an idempotency key, and the keys are deterministic and generation-scoped. That is careful work.

Now the question you actually asked. Several customers uploading large lists at once: yes, they will block each other. Your four sending tasks each declare a limit of twenty simultaneous jobs, on a queue shared by the whole platform. The setting that would give each customer their own share — a concurrency key — appears zero times in your codebase. Workspace is attached as a tag, which is useful for searching and does nothing for scheduling.

So one customer running a backfill fills all twenty slots and everyone else waits.

How to stop one large customer delaying everyone: add the workspace as the concurrency key on those four tasks. That is the whole fix, and it is the highest value-to-effort item in this entire review.

What limits to test before launching, concretely: the largest realistic single import, run by three workspaces at once, while a fourth workspace's follow-ups are due. Measure whether the fourth workspace's sends leave on time. That test is the proof you want, and it is worth writing before the Kaizen cohort rather than after.

At what size does this approach stop working? Not soon, and not because of Trigger.dev. The limits you will hit first are your provider's sending caps and your database, not the queue.

What proof would I want before trusting it with customers? The concurrency test above, plus a deliberate provider-outage drill — cut the connection mid-send and confirm nothing double-sends and nothing gets permanently stuck. Right now, both of those would fail.

5. Is the Trigger.dev / Mastra split sensible? Are we giving Mastra too much or too little?

The split is right and I would not change it. Mastra's boundaries are correctly drawn.

Your stated model — Trigger.dev owns the job, Mastra is called when a step needs AI, results come back to Trigger.dev which handles persistence and failure — is exactly the right division, and it is what the code does.

Mastra does not own permissions, billing, queues, provider sending or the official analytics numbers, which matches your stated intent. That restraint is correct and worth keeping deliberate, because the pull will be to let it creep.

Which AI steps should move into Mastra first? The ones already there are the right ones. If anything, the reply-classification work still running as Python cron jobs is the obvious next candidate — not because Python is wrong, but because it duplicates something Mastra already does, and duplication is your actual problem.

How to compare a new AI workflow with the current one: you already have the answer built. Mastra's evaluation harness with frozen test cases is exactly the right mechanism, and you have real eval scripts wired up. Use it as the gate — a new workflow ships when it beats the old one on a fixed set, not when it looks better in a demo.

Which AI results should always need human approval? One category stands out, and it is not a technical judgement: any copy that asserts a fact about the recipient's business — particularly a regulatory violation. That is covered separately in the findings, and it is the one place I would put a person in the loop unconditionally.

Are quality, cost, speed and failure rate the right measures? They are the right four. I would add a fifth: *rate of assertions about the recipient that cannot be traced to a source record.* For your product that is the one that carries real risk.

6. Is one shared database with workspace separation the right model?

Right model, and mostly well implemented. But there are holes in it today, and one of them cannot be closed without a schema change.

The model is correct. One database with a workspace on every row is the standard answer for 20–50 customers, and separate databases per customer would be the wrong trade — far more operational work, for isolation you can get from rules.

Much of the implementation is genuinely good, and I want to be specific about that because it should shape where you spend. Your login layer correctly rejects unauthenticated API calls. Your service-role key is server-only and properly documented. The strongest routes take a workspace id from the caller and then verify that caller actually belongs to that workspace before touching anything. That is exactly right, and 22 routes do it.

The problem is that it is not done everywhere. Around ten routes accept a workspace id from the caller and never check membership. And three tables holding prospect emails and phone numbers are readable by every logged-in user, because those tables have no workspace column at all — the rule cannot be written until the column exists.

"Most of the time" is not a security model. A tenant boundary either holds everywhere or it does not hold.

Where I have seen this leak before, and it matches you exactly: not through a clever attack, but through the one endpoint written on a Friday that took the customer's word for which workspace they were in.

What tests I would expect before launch: two accounts in two workspaces, and an automated test that walks every single route attempting to act on the other's data — expecting a refusal every time. Written as a test that runs on every change, not as a one-off check. That test is what turns "we were careful" into something you can prove.

When would separate databases become necessary? Not at 20–50 customers. Later, and for a specific customer who contractually demands it, not as a general architecture.

7. Is the EmailBison launch process safe enough? What if Bison succeeds but we don't get the answer?

You asked precisely the right question, and the honest answer is that the current default path gets it wrong.

The campaign launch process you described — prepare paused, verify, explicit Launch, read back before showing live — is sound. Deploy is protected by a lock, and it refuses to redeploy a campaign that already has a provider id. That part is fine.

But your question was about the lost response, and that is where it breaks. You asked: what should happen if EmailBison succeeds but Inreachly does not receive the answer?

The correct answer, which your newer code already implements, is: assume it may have been sent, never retry blindly, and resolve it by asking the provider what actually happened.

The default path does the opposite. It treats a lost response as an ordinary failure, reverts the message to ready, and retries — sending again. It also omits the idempotency key that would have made a duplicate impossible. And the safe path is behind a setting that is empty by default, so new customers are on the unsafe one.

And even on the safe path, the resolution never happens. It correctly stops and marks the outcome unknown — then nothing checks with the provider, and the operator screen has no button. Your database has a field for exactly this with three possible values; two of them are never written by any code.

What to check before saying a campaign is live: you already do the right thing here. Keep it.

Should you test with a small number of leads first? Yes, and I would make it a product feature rather than a discipline — a mandatory seed send before a campaign can go to full volume. It protects your customers from their own mistakes, and it protects your platform's reputation from both of you.

Which provider limits do you need confirmed in writing? Three, and one is urgent: whether EmailBison applies its unsubscribe and blacklist to the threaded-reply endpoint your follow-ups use. Nothing in your code answers it, and if the answer is no, your follow-ups have no unsubscribe protection at all. Also get their rate limits and their bounce/complaint webhook guarantees in writing.

8. Is the additive database cleanup approach correct?

Strongly agree — and you are already doing it better than most.

This is the part of your plan I would change least. Across all 75 live migrations there is not one DROP TABLE, not one DROP COLUMN, and not one TRUNCATE. Nothing irreversible ships as a migration. That discipline is rarer than you might think and it is worth keeping deliberately.

A few other things are quietly well done and worth knowing, because they tell you where *not* to spend: money is stored as whole-number micro-units rather than decimals, there are no dates stored as text, and there are 646 validity rules enforced by the database itself rather than trusted to application code.

Two things I would change. First, the old migrations/ folder has been dead since March and CI never looks at it — but it contains 29 files of plausible, current-looking SQL sitting outside every safety check you have built. Delete it, or an agent or new hire will eventually read it as the truth.

Second — and this is the answer to "what proof before deleting an old table" — proof is *evidence that nothing reads it*, not an opinion that nothing should. That means logging reads before removal. You have the opposite situation right now on eight tables: code that reads tables which may not exist. Resolve that before you remove anything else.

Are you delaying too much cleanup? No. Cleanup is not your risk. Tenant isolation is.

9. Would you be comfortable giving this direction to the developers for detailed planning?

Yes — with one change to the plan, and three things fixed before any external customer gets an account.

What I agree with. The stack. The staged-improvement approach over a rebuild. Supabase as the source of truth. One shared database with workspace separation for this customer count. Trigger.dev owning long-running work. Mastra kept narrow. Additive database cleanup. Building for the pilot rather than for imaginary scale. That is most of your document, and it is right.

What I would change. One thing: the document is written as a plan to adopt an architecture you have already largely adopted, and it therefore has no completion criteria. Rewrite it as a *migration* document with a kill list — every Python route and cron job marked port, keep, or delete, with a date. Without that, the two-system state is permanent and you pay for both forever.

What looks too complicated. Not the stack — the duplication. Two schedulers, two web servers, two webhook receivers, two billing paths and six reconcilers. None of that is inherent to your design; it is the residue of an unfinished migration. And around 1,827 files of AI-tooling configuration with 1,197 dated planning documents makes the repository much harder to navigate than the actual product warrants.

What is missing. Three things, in order. A suppression list — the database currently cannot record that someone unsubscribed. An automatic pause when bounce rates spike. And a documented answer to who your data goes to, which your own April audit already flagged as blocking.

What could become expensive later. Maintaining two systems is the obvious one. Less obvious: the tenant-separation gaps get more expensive the longer you wait, because every new endpoint written against the current pattern is another one to audit. Fix the pattern before the team grows, not after.

What developers should investigate more deeply. The eight tables queried by code that no migration creates — that is a one-query question with two very different answers. Whether EmailBison enforces unsubscribe on the reply endpoint. And whether every AI call uses the customer's own vendor key or falls back to a platform key.

Do I approve the overall direction? Yes. To be clear about what that means: I would give this direction to developers tomorrow. I would not open it to external customers until the three items in "Do now" below are done — not because the system is bad, but because those three are the ones where a mistake is not recoverable.

Recommendation

What to do, in order

Sorted by impact against effort, as you asked. No prices here — this is sequence, not cost. The one rule I would hold you to: the things in 'Do now' are not optional, because everything else you pay for later is worth less until they are done.

Do now

High impact, low effort — the cheap wins you should not be without

  • Add the workspace as the concurrency key on the four send tasksOne line each. Removes the noisy-neighbour risk you asked about entirely.
  • Set a 15-second timeout on the provider calls in the TypeScript clientOne line per call. Stops a provider wobble freezing sending for every customer for five minutes.
  • Pass the idempotency key and check the unknown-outcome error on the legacy send pathTwo small changes. Stops the same email being sent twice after a dropped connection.
  • Turn the safe send path on for every workspace, not an allow-listAn environment variable. Right now new customers default to the unguarded path.
  • Add the ownership check to the three endpoints missing itThe helper already exists in your codebase. An afternoon.
  • Rotate both committed Google credentialsMinutes. They are live, and repo access is expanding.
  • Run the eight-table queryOne query. Decides whether billing is dead code or your schema history is wrong.
  • Create the two missing GitHub labelsFive minutes. Turns your permanently-silent deploy alarm back on.
  • Check your branch protection settings and screenshot themTen minutes. Determines how serious the merge-gate finding actually is.
  • Ask EmailBison, in writing, whether unsubscribe applies to the reply endpointOne email. It is the largest unanswered question in this review.
  • Check your Supabase connection count and plan limit, and your Trigger.dev concurrency ceilingTwo dashboard screens. Both are cliffs that fail everything at once, and you cannot see them coming.

Focus on over time

High impact, high effort — real projects, worth planning and funding

  • Build a real suppression list and allow 'unsubscribed' as a lead statusA migration, a guard before every send, and a backfill. The single most important missing capability.
  • Automatic campaign pause when bounce or complaint rates cross a thresholdThe threshold check already runs daily. Give it the ability to act, and run it more often.
  • Rewrite the tenant-separation rule so it runs once per query, not once per rowCurrently two database lookups per row on every protected table. Invisible now, a cliff later.
  • Switch credit accounting from observe mode to enforcement, and rate-limit the public endpointsThe machinery is already built. Today a customer's spend is measured and capped by nothing.
  • Fix the merge gate, and make auto-merge require a human for high-risk changesYour tests are good. This is what makes their result matter before the team grows.
  • Add tenant columns to the three tables that lack them, then write real policiesCross-tenant reads cannot be closed correctly until the column exists.
  • Write the migration kill list and finish or formally stop the Python migrationConverts an open-ended cost into a finite project. A morning to write, months to execute.
  • Subscribe to spam complaints and act on themCurrently not even received. Complaints damage domains faster than bounces.
  • Resolve unknown-outcome sends — a provider lookup, or an operator buttonTwo of three resolution methods are defined in your schema and never used.
  • Rewrite README and DEVELOPER-HANDOFF to describe the actual productA day. The highest-leverage day available before anyone joins.
  • Map your AI vendor data flows and produce a sub-processor listYour own April audit called this a launch blocker. Customers will ask before signing.
  • Decide, deliberately, whether the regulatory-violation angle ships in the productA positioning decision, not a technical one — and it is yours to make consciously.

Second priority

Low impact, low effort — do them when they are in your way anyway

  • Add rate-limit (429) handling and backoff to the TypeScript provider clientA correct implementation already exists in your Python code to copy.
  • Add jitter and re-check the send window before sendingStops the Monday-morning pile-up. Small change, direct deliverability effect.
  • Delete the dead migrations/ folderRemoves a trap. Do it after the eight-table query, since it is the only record of them.
  • Pin the lookup path on the two isolation functionsOne line each. Hardening, not a hole.
  • Move the verification email out of the URL query stringOne line. Keeps addresses out of third-party access logs.
  • Use a longer random token for the shareable /r/ linksCopy the pattern your own /lm/ route already uses.
  • Repair rows stuck mid-send instead of counting themThe reconciler already finds them and does nothing.
  • Label the April audit report accurately, or move itIt reads as assurance and is not. Five minutes.
  • Move database connections to transaction pooling where each service allows itSession mode ties connection count to process count, and you run 30+ processes.
  • Record send latencyIt is the number that sets your sending capacity, and nothing measures it today.

Leave out

Low impact, high effort — actively decide not to do these

  • Rewriting the working Python pipeline in TypeScript for consistencyMonths of work for no customer-visible change. Move things because they duplicate, not because of language.
  • Separate databases per customerLarge operational cost for isolation you can get from policies at 20–50 customers.
  • Chasing test coverage as a numberYour tests are good; the problem is wiring, not quantity. Fix the gate, not the percentage.
  • Adding CONCURRENTLY to historical index migrationsOnly future ones matter. Rewriting history buys nothing.
  • A full audit of the remaining ~216 database functions right nowNeeds database access, and the two that matter are already identified.
  • Cleaning up the 1,197 AI planning documentsAnnoying, not risky. Do it opportunistically, or leave it.

One sentence on where this leaves you. The engineering underneath this system is consistently better than the controls wrapped around it — the schema discipline is genuinely strong, the tests that run are real, the payment handling is better than most, and the newer sending path is textbook. What is hollow is the connective tissue: a merge gate that checks an empty list, a router that skips tests on the riskiest changes, an alarm whose labels do not exist, and a safe send path switched off by default.

That is a much better problem to have than the alternative, because most of the "Do now" column is hours rather than weeks.

Two things I could not check, stated plainly so they are not mistaken for clean bills of health. I have had no access to your Supabase database, so every database finding describes what your migration files say rather than what your live schema does — confirm each against pg_policies and pg_tables before acting. And your branch-protection settings are not readable without admin access, so I cannot tell you whether the merge-gate finding is a decorative problem or a live one.

And one thing worth saying directly. You asked for a review of whether the direction is sensible. It is. You have built more of it than your own document credits you with, and the parts you built carefully — the send claim, the fingerprinting, the fail-closed behaviour, the per-tenant vendor keys — show someone who understands the failure modes. The gap is not knowledge. It is that the careful version exists alongside the quick version, and the quick version is the default.

Turning the careful version on is most of the work.