CRM Matchback: what "working 100% of the time" actually entails

September 15, 2026

Hyuchia

CRM Matchback: what “working 100% of the time” actually entails#

An analysis across dopemail and clickrun, with the pre-launch data audit run against production.


The one thing to read#

The 47 accounts that have crm_match_back_enabled today hold $832.3M of CRM revenue in contacts.crm_jobs_total_value. After correcting two arithmetic defects that are live on production right now, the real figure is ~$448.7M.

The number we would show a customer today is 1.86× the truth.

That is the answer to the milestone — “we need to know what they are going to see before we show them.” We now know. It isn’t ready, and the reasons are specific and fixable.

Reported Correction Real
Fleet CRM revenue $832.3M
— refunds counted as revenue −$74.1M
— one account missing a ::cents parser −$309.6M
Corrected ~$448.7M
…of which locked out of every report by sentinel dates $78.9M

Part 1 — The good news: most of this is already written#

There is an unmerged branch, feat-MatchBacksWithCRMDataCont, three commits ahead of main, last touched 2026-09-09. It has never been merged to main, qa, or production. It contains:

Commit What it is
a59c51774 Refund-safe trigger + date hardening + recompute rake task. Fixes defects #1 and #3 below. Ships safe_timestamptz, a 1900 sentinel floor, sign-preserving sums, a conditional trigger create (no ACCESS EXCLUSIVE lock on re-apply), rake contacts:recompute_crm_fields, plus an upsert-wipe fix and a delayed-job timeout raise. 900 insertions, full suite green.
f0bb389f1 crm-customer-success skill — a self-learning CRM mapping specialist. Already run against 5 accounts on 5 sources, with confirmed FK/amount/date profiles and per-source quirks recorded. This is the “AI does the mapping” goal, ~70% built.
ec933e62b Pulse “CRM Data” account tab + AccountCrmHealth service + specs. Admin visibility into what an account’s CRM data actually looks like.

I verified against the production replica that the buggy version of the trigger is what is running — calc_crm_jobs_total_value still uses [^0-9.], and safe_timestamptz does not exist in the production database.

So the first move is not to build anything. It is to land work that is already done and tested.


Part 2 — The pre-launch data audit (the milestone)#

Defect 1 — Refunds are counted as revenue. Confirmed, quantified.#

db/sql/create_contacts_crm_fields_trigger.sql, live in production:

SELECT SUM(regexp_replace(li.value->>'amount', '[^0-9.]', '', 'g')::NUMERIC(14,2))

The character class [^0-9.] deletes everything that isn’t a digit or a period — including the minus sign. A -500.00 refund becomes 500.00 and is added. The guard regex on the next line runs on the already-stripped string, so it can’t catch it. The sign flip costs 2× the absolute value: you add instead of subtract.

Contacts with ≥1 negative line item 28,607
Negative line items 76,192
Absolute value of negatives $37.0M
Net overstatement $74.1M
Accounts affected 28 of 47

There is a second copy of the same defect in app/services/dashboard_summary.rb:338 (REVENUE_MTD_SQL), which drives the customer-facing dashboard revenue tile.

Worth noting: app/models/crm_audience.rb:33-42 and the React preview card ContactPreviewCard.jsx:70 both handle sign correctly. So the “Lifetime Value” a customer sees during CRM setup already disagrees with what the database stores whenever a refund exists.

Defect 2 — A single missing ::cents parser is 37% of the fleet total#

Account 5151 — Easy Garage Door repair (HousecallPro) reports $312,682,008 across 1,951 converting customers. That is $160,268 per garage-door customer.

Its jobs_config:

{"fk": "customer.id", "dataType": "job",
 "cdm": {"date": "customer.created_at",
         "line_items": {"key": "__self__", "amount": "total_amount", "service": "name"}}}

HousecallPro total_amount is in cents. The ClickRun default mapping for this source is amount::cents. This hand-written override dropped the parser. The raw data proves it — line-item amounts are 1317592, 1200000, 1003680, 470888, with no decimals and natural cent endings. Those are 13,175.92,12,000.00, 10,036.80,4,708.88.

The control is decisive: account 4103 — Value Garage Door Service, same industry, same CRM, running the ClickRun default, reads $3,371 per customer.

312.7Mshouldbe 3.13M. $309.6M of the fleet total — 37% — is one mis-mapped field on one account.

That same config also maps the job date to customer.created_at, so every job on a customer shares the customer’s creation date. Confirmed in the data: contact 699817992 has three jobs all stamped 2024-06-14T17:48:21Z. The “conversion happened after we mailed” window is meaningless for this account.

Defect 3 — Sentinel dates silently delete a third of some accounts#

ServiceTitan emits 0001-01-01T00:00:00Z for unknown dates. The production trigger has no floor, so crm_first_seen_date stores it verbatim.

The matchback predicate is contacts.crm_first_seen_date > mail_pieces.dispatch_date. For 0001-01-01 that is always false. Those contacts can never match, in lead mode or client mode.

Contacts with a CRM date (enabled accounts) 325,905
…with a pre-1900 sentinel 60,705 (18.6%)
Accounts affected 14
Value locked out of every report $78.9M

Worst affected:

Account Sentinel contacts % locked out Value locked out
5587 Brickliners Corp 4,259 / 4,981 85.5% $1.9M
3750 Mr. Electric of Land O’ Lakes 12,494 / 21,990 56.8% $63k
3785 Top Flight Heating & Air 1,740 / 3,115 55.9% $3.8M
1546 Comfort First Heating & Cooling 15,459 / 30,933 50.0% $21.7M
5237 Extreme Heating & Cooling 2,279 / 4,561 50.0% $1.7M
1026 Van Delden Wastewater 6,637 / 14,783 44.9% $5.2M

This one is especially bad because it is invisible. The customer sees a smaller number, not an error. Comfort First’s report is missing half its customers and $21.7M, and nothing anywhere says so.

The fix is on the unmerged branch: a >= '1900-01-01' floor plus safe_timestamptz so a malformed date returns NULL instead of aborting the whole insert batch.

Defect 4 — The mapping is hand-built and ~40% of it is wrong#

Only 25 of 1,732 list pulls carry an explicit jobs_config. Reading all 25 in production, roughly 10 are broken:

List pull Account Source What’s wrong
1806 5792 jobber job date mapped to client.firstName
1657 4612 servicetitan fk mapped to job.completedOn — a date, not a contact id. No line items at all.
1583 921 jobber fk mapped to quoteStatus — a status string
1581 397 jobber fk mapped to jobNumber — the job’s own id, not the client
1612 1622 servicetitan no amount; service mapped to doNotMail (a boolean)
1604 2440 servicetitan no amount
1584 4743 housecallpro no amount
1838 5918 jobnimbus no amount; service mapped to location.id
1752, 1668 5463, 5272 gohighlevel no date mapped

A wrong fk means zero jobs link to any contact — the account’s revenue report is a flat 0andnobodycantellwhy.The‘crm−customer−success‘skillalreadydiagnosedexactlythisonaccount397:∗"jobNumberisthejob′sOWNid,notacontactFK—wrongFKwasthe0 cause.”*

This is the single clearest argument for the “AI does the mapping, not the user” goal. A four-dropdown form asking a roofing contractor which field on a Job identifies the Customer has a ~40% error rate in the field, and every error is silent.


Part 3 — Which CRMs we can and cannot get revenue from#

This answers the goal directly. Revenue does not travel through the contact CDM — it travels through a second mapping, hasMany.jobs.cdm, declared on the contact data type and pointing at a money data type.

Ship today — 8 sources#

Source Money type ← FK amount Line items Caveat
ServiceTitan invoice ← customer.id (number) items[].total real, nested Header-only invoices yield $0
ServiceMinder appointment ← ContactId Total __self__ Richest payload — 7 money columns; gross margin computable here and nowhere else
JobNimbus invoice ← contact.id items[].price real, nested amount beats default price by ~6%; multi-invoice-per-job is additive
Jobber job ← client.id total __self__ completedAt is already synced and is a better date than createdAt
HousecallPro job ← customer.id amount::cents synthesized in parser Only status == "paid" invoices count
Acculynx job ← primaryContact.id salesAmount __self__ Milestone-gated — ~80% of jobs legitimately $0. Not a bug.
GoHighLevel opportunity ← contactId monetaryValue __self__ Pipeline value, not booked revenue. Must filter on status.
FollowUpBoss deal ← personIds[] price __self__ Deals have no updatedAt → incremental runs hard-stop at 2,500. Also attributes each deal to every associated person — dedupe by deal id.

One-line fix, data already in Mongo — HubSpot#

HubSpot’s hasMany.jobs.cdm maps only date. But setPropertiesParam already requests every deal property, so properties.amount, hs_acv, hs_arr, hs_closed_amount and closedate are all sitting in Mongo today. Adding

line_items: { key: '__self__', service: 'properties.dealname', amount: 'properties.amount' }

turns HubSpot on with no re-sync. Caveat: the fk takes only the first associated contact (associations.contacts.results.0.id).

Cannot ship — 8 sources#

Source Why
JobProgress (Leap) The job request only asks for includes=address,work_types,trades. No money field is requested, so none lands in Mongo. Needs an API-capability investigation and a full re-sync — engineering, not mapping.
ProLine The project is the contact. No second money object exists. Partner ask.
BuilderPrime Single data type (client). No money object implemented, though the vendor doc is titled “GET Leads Opportunities” — possibly reachable.
SalesRabbit lead only. Some orgs may populate customFields.value; otherwise nothing.
Lightspeed / Salesforce / Shopify / Square Non-functional stubs — baseURL: "", coming_soon: true. Shopify orders and Square payments would be trivially money-bearing but nothing is built.

One trap worth internalising: supported: false does not mean “not synced.” ExternalSync.syncAllDataTypes iterates every declared type; the flag only controls whether a failure aborts the run and whether it shows in the UI picker. JobNimbus’s invoice type is supported: false and carries all of JobNimbus’s revenue.

A structural hazard#

There is a leadValue / clientValue pair in the CDM vocabulary (emptyFullCDM in src/dataSources/index.js), documented in docs/DATA_SOURCES.md — and not one of the 17 sources populates either. Worse, getDataSourceDataTypeCdmDefault ends with a pick() against that allow-list, so any revenue key added to a source’s contact cdm that isn’t in the list is silently dropped. Use the per-job route (hasMany.jobs.cdm), not the contact-rollup route.


Part 4 — AI does the mapping#

Most of this exists. The crm-customer-success skill on the unmerged branch is a 549-line derivation pipeline: read the source default → inventory the real fields in iPaaS Mongo → derive fk/fkType/line_items/amount/date from actual data → project current-vs-ideal dollars → emit a readiness verdict → write the learning back to a JSONL store that renders into a per-source profile.

It has already run on 5 accounts across 5 sources and learned things a human spec would not have:

  • fkType must match the id shape, not the configured value. Opaque/base64/prefixed ids (Jobber GID, JobNimbus GID, HousecallPro cus_, AccuLynx GUID) → string. Pure-numeric (ServiceTitan) → number. A numeric fkType against a string id links zero jobs = $0.
  • A 100% FK resolve rate with near-zero contact links has two causes, and they need opposite fixes: wrong FK (mapping) vs. contacts absent from iPaaS (engineering). On AccuLynx account 38, 19Mofsoldjobsreferencecontactsthatwereneversynced—themappingwasperfectandthereportstillreachedonly 1.8M of ~$21M.
  • Readiness keys off the master pull’s freshness, not connections.status, which is frequently a stale error on healthy accounts.

What’s missing#

  1. The DATA-10 job-level fallback is prescribed but not implemented. The skill tells you to add a job-level amount for sources where line_items is often empty (HousecallPro: 41% empty, 16% empty-with-a-total), and says the trigger uses COALESCE(sum(line_items), job_amount, 0). It doesn’t. That fallback was explicitly excluded from commit a59c51774. The prescription cannot currently be applied.
  2. SubscriptionTemplate has no cdmMap field on either side, so a generated template can carry conditions but not field mapping.
  3. No templateId link from a Subscription back to the template it came from — so there is no feedback loop on which templates produce good vs. runaway automations. The UI currently detects “is this template selected” by JSON.stringify deep-comparing conditions.
  4. SubscriptionTemplate.validateRules uses || where Subscription uses && — a template saves happily with a field but no operator. A model-generated malformed rule set will persist silently.
  5. No write-back path. The skill prescribes a mapping; a human still has to apply it. Closing that loop — propose → review → apply → verify — is what turns this from an audit tool into the actual goal.

The raw material for an AI to work from is genuinely rich: ExternalObjectDataField stores discovered dot-paths, inferred types, and up to 500 observed values per field, with cross-account frequency ranking; POST /connections/:id/sample returns 25 real records per data type; and GET /operators exports every operator with machine-readable type constraints. Two gaps: date and boolean fields deliberately store no sample values, and there’s no total-distinct count, so a model can’t tell an 8-value enum from free text.


Part 5 — Look back farther, and resync without firing automations#

This is the hardest item on the list, and it is worth being blunt about why.

How far back we currently reach#

Encouragingly, for most enabled accounts CRM history already predates mail history — account 984’s CRM data starts 2015-01-09, nearly 10 years before its first mail. But two accounts are inverted: Blue Collar Roofing’s CRM data starts 283 days after its first mail, and Dave’s World’s starts 109 days after. Mail sent before that cutoff can never be matched, and nothing surfaces that.

The structural cause is documented: complete syncs never send updatedAfter and page newest-first, so any CRM larger than the per-run cap has a permanent coverage floor at whatever the initial backfill reached. Nothing walks deeper. There is no backfill path.

Why a backfill fires triggers today#

The write is unconditional:

update: { data: item, dataCreatedAt: ..., dataUpdatedAt: ... }, upsert: true

No hash, no content comparison. Mongoose then injects $set: { updatedAt: now } on every op, so even a byte-identical re-sync genuinely modifies the document and emits a change-stream event. Re-fetching 500,000 unchanged records generates 500,000 events.

The existing isChangeExpired guard covers exactly the wrong half:

if (eventType === 'update') return false;   // updates are NEVER expired

It suppresses inserts whose dataCreatedAt is >24h from our createdAt — which is why a first-ever sync doesn’t spam. A deeper re-sync is by definition mostly records we already have, so it lands entirely in the unprotected update lane.

triggerOnceLock is a real backstop — one successful trigger per record per subscription, forever — but a backfill’s whole purpose is reaching records that never fired, and those all have a free lock slot.

The thing to verify before designing anything#

prevData comes from the MongoDB change-stream pre-image, requested as 'whenAvailable'. If changeStreamPreAndPostImages is not enabled on the externalobjects collection, prevData is {} for every update — which makes changed return true for every non-undefined field and reduces changedTo to plain equality.

If pre-images are off in production, a backfill is not a performance problem. It is a mass mailing. The code logs a one-time warning (preImageWarningShown) — check production logs for it before anything else.

What a safe backfill needs#

  1. Mark the write — a persisted syncRunId/backfill field on the document, since the change stream only ever sees the document.
  2. Honour it in the handler, beside the existing expiry gate.
  3. Filter server-side. The watch currently uses an empty pipeline [] — every write for every account streams into Node. A 500k backfill will trip the 75%-heap drop and lose real events alongside the backfill ones. Suppression has to happen in a $match, not in JS.
  4. Build a dated fetch mode. shouldApplyFilters = !fullSync && !!updatedAfter actively discards the date on full syncs, and MAX_COUNT = 10000 caps any single run. “Sync from date X” does not exist as a capability.
  5. A third job type — a backfill can’t ride external_sync:complete without corrupting its checkpoint and health bookkeeping.

Cheap interim option: the existing isChangeExpired already has the right comparison written; it’s just short-circuited for updates. Applying the dataUpdatedAt vs updatedAt > 24h test to updates would suppress nearly all of a historical backfill immediately. The cost is spelled out in the existing TODO — relation-only changes, where the CRM’s timestamp is stale but our parse genuinely changed, would stop firing. That trade is worth pricing.

Retrying failed triggers#

SubscriptionRun already persists everything a retry needs, including the full event. What’s missing is the schema (attempts, nextRetryAt, a {status, nextRetryAt} index), a classifier (the taxonomy is already computed at the error site — it just chooses log wording instead of a retry disposition), and a sweeper. Model it on retryDisabledSyncJobs, which already implements widening backoff and a per-sweep cap.

Two traps: triggerOnceLock is set false on error, so a scheduler must hold it while a retry is pending or a concurrent live event double-sends. And the webhook timeout is 5 seconds against a Rails endpoint that creates an AutomationRun → Campaign → Contact → MailPiece — a timeout very plausibly means dopemail did the work. Blind retry mails twice. X-Subscription-Run-Id is already sent; making AutomationRun unique on it is the real fix, and board card #2626 already exists for exactly this.

Also: three paths strand a run as pending with the lock held forever — no URL configured, a localhost URL outside dev, and process death mid-flight. The first two create the run before checking; moving those two checks above the create is a two-line fix.


Part 6 — EDDM#

EDDM produces no mail_pieces at all. ScheduleCampaign short-circuits to generate_eddm_atomically! before create_mail_pieces is reached; EDDM volume lives entirely in mail_route_pieces, whose rows are postal routes, not pieces. The matchback reads MailPiece.shipped, so EDDM is not producing bad matches — it is wholly invisible.

To match “contact created in CRM after we mailed that route”, you need to know which carrier route an address falls in. Three options:

  • PostGIS point-in-polygon — not possible. There are no carrier-route polygons, and none can be derived. USPS’s ArcGIS feed returns each route as a MultiLineString of the streets the carrier walks, not a boundary. The codebase says so in three places. Routes interleave (odd/even sides of a street can differ), so a hull over the polylines isn’t a boundary either. eddm_routes.centroid_lat/lon is explicitly a bounding-box midpoint for “radius planning and map fallback” — not a location.
  • ZIP-level join — possible today, but it isn’t matchback. contacts.zip → mail_route_pieces.zip_code, zero new schema. But a ZIP has 10–40 routes, so unless a campaign bought every route it credits converts who were never mailed. It can be made honest: eddms.zip_crids (routes bought) over eddm_usps_snapshots.route_count (routes in the ZIP) gives an exact coverage fraction. Publish the ratio, never merge it into the per-address total.
  • A carrier-route column on contacts — the real fix, and cheaper than it looks. contacts.tec_mail_data holds the full TecMail CASS response, and CASS output conventionally includes a carrier route code. We currently read only 5 keys from it. Inspect one production payload for a CRRT/Carrier_Route key — that’s a ten-minute check that decides everything downstream. If it’s there, this collapses to a column + a trigger + a backfill, and EDDM matchback becomes a clean equi-join on (zip, crid_id). If it isn’t, the ATTOM Mongo mirror already projects PropertyAddress.CRRT in the same C001 format, reachable per-contact via the existing attom_internal_id.

Two further pieces of work either way: mail_route_pieces is completely un-denormalized (no account_id, no campaign_id, no dispatch_date — every account query pays a 3-table join), and EDDM “pieces” are routes, so the ROI denominator needs EDDM-aware unit pricing or the cost side will be wrong.

Date semantics: use dispatches.date. mail_route_pieces.sent_at is stamped CURRENT_TIMESTAMP by a status trigger at the moment an operator flips the status — it’s an action timestamp, not a mail date, and the codebase already coalesces around it.


Part 7 — ATTOM appends#

The append feeds exactly three report buckets (home_value, year_built, home_size) plus a last-resort lat/lng fallback. 16 of the 21 stored keys have no reader anywhere.

The pipeline is one contact at a time, two sequential un-timed HTTP round trips each, no batching, no retry, no rate limiting, no address-level dedup across accounts, and no index behind the “needs an attempt” scope. Meanwhile the other ATTOM integration in the same repo — Property Plus — pulls 178,957 documents from the same cluster in 2.9 seconds via one Mongo aggregation, using a pooled client, and already writes contacts.attom_data in the identical shape.

The dominant problem is a leaky bucket, not throughput. attom_attempted_at is stamped before the lookup and is never reset by anything. A 429, a timeout, a 5xx, a QA-host misconfiguration, or a house-number mismatch all mark the contact permanently ineligible. There’s a rake task in the repo cleaning up a batch of these failures whose error was a TCP connection failure to the QA host — every one of those contacts is now permanently un-appendable with no code path to recover them.

Before optimising, check whether we need it at all for the report:

  • Census ACS is already on every contact, free: median_home_value and median_year_built at block-group resolution, with census_block_group_id indexed. Covers 2 of the 3 dimensions. The DOPE ID UI already renders both.
  • Data Axle carries all three (real_estate.estimated_home_value / year_home_built / square_footage) and we store the entire raw payload in contacts.data_axle_data. Nothing reads it. One query settles it: SELECT count(*) FROM contacts WHERE data_axle_data ? 'real_estate'.

Only home_size has no existing substitute. For bucketing converters into six value bands, block-group medians are arguably fine.


Part 8 — Disconnect alerting#

The two halves of this pipeline are inverted.

Event Slack? dopemail handler? Tested?
customer.connection.warning no yes no
customer.connection.error no yes no
customer.connection.stalled yes NO — discarded with a 200 OK yes
customer.connection.degraded yes NO — discarded with a 200 OK yes

IpaasWebhookHandler maps only warning and error; the other two hit return unless handler and ClickRun receives {"status":"success"} and believes the alert landed. The two events with the most engineering behind them are the two that go nowhere.

Everything else worth knowing:

  • There is no warning status. Connection.status is enum: ['active', 'error']. setConnectionWarningFromJob is three lines with a TODO and does not touch the document. So dopemail’s fetch_connections reports active for a connection at 9 consecutive failures, AccountStatistic records it as connected, and the onboarding checklist shows a green check.
  • No retry, no timeout, no queue on the webhook. A bare axios.post in a try/catch ending in logger.error + // TODO. And the warning fires on exactly one edge (prevWasSuccess) — if dopemail is down at that moment, that account never gets a warning, ever. Failures 6–9 fire nothing.
  • The customer is never told. One hardcoded recipient, [email protected]. No ActivityHistory row, no in-app state — there is no Notification model in dopemail at all. A customer whose CRM has been dark for a week sees a normal, healthy integration card.
  • Recovery is silent. The daily sweep flips status back to active with no webhook, so the HubSpot ticket opened by the disconnect is never closed programmatically.
  • Token failures are invisible. The most diagnostic line in the system — “a rotated refresh token may have been lost and the connection may now need re-authorisation” — is logged at error level with full context and nothing consumes it. An invalid_grant is flattened into a generic run failure and costs 10 runs (~10 working hours, spanning two calendar days if it happens after 22:00).
  • Complete-job failures are fully exempt — the status check early-returns for external_sync:complete. A daily full sync failing for months never warns.
  • The automation Slack channel has zero callers.

For the HubSpot question: there is no outbound health-signal sync. HubspotClient has 4 properties (mail pieces, automation counts, account id); salesforce_job.rb — despite its reputation — is a billing/usage sync with 16 properties, none of them connection health. AccountStatistic#crm_status is computed and stored and nothing watches it. Worth a conversation with whoever owns the HubSpot side about which property should carry this.

The template to copy already exists in-repo: StuckJobsMonitor does Sentry + email with a 6-hour cooldown. Connection health needs exactly that shape.

Over-triggering detection#

Not found — in either repo. What exists is user-configured suppression: per-automation throttle_count/throttle_days (default 50/day) and duplicate suppression on address_1/city/state/zip. Both are silent when they fire. AutomationHealthService reports run-status mixes and error rates but has no baseline or deviation concept, and there is no alert channel on the Rails side at all.

The inputs are one line away: Automation.batch_load_trigger_counts already computes last_7_days_triggers and last_30_days_triggers per automation in two grouped queries, and both are already in lists_as. A 7d-vs-30d ratio is the signal. The right multiplier to turn a trigger spike into a dollar figure is AutomationPurchaseSummary, which already estimates per-trigger cost — because one trigger fans out to a whole campaign (ceiling 125,000 contacts), so “fired 500×” only means something relative to the mail it produced.

Two caveats: the throttle counts triggers, not pieces, and the “Send Anyways” force path bypasses duplicate, throttle and geo checks entirely (board card #1958 already tracks the double-click case).


Part 9 — Automations UI#

The shell has been modernised twice; the trigger builder has not been touched since March 2026 and runs on a different design system entirely — react-select, react-querybuilder with its stock CSS, a bespoke TinySelect/DatePicker, and a separate 19KB integrations.scss. Zero Dope components in SubscriptionEvent.jsx and SubscriptionEventConditions.jsx. SubscriptionFieldMap.jsx renders a duplicated toggle twice per row and carries the comment // TODO, chunk entries instead of this pushing to array (ai made it...).

The very first screen, AutomationTrigger.jsx, has three hand-written inline style objects with a hardcoded hex and the comment // TODO pill tabs, duming styles here.

Also: zero of the 31 files in automations/ use DopeContentPage, while ~30 files elsewhere do. AutomationsAndRuns.jsx is dead. AutomationThrottleSettings.jsx is a shared component that AutomationReview.jsx re-implements inline — it even re-declares the option arrays that the shared file exports.

There is already a stale automations-ux-overhaul branch from 2026-07-22.


Part 10 — Infrastructure#

Mongo#

The 138 GB is two collections:

Collection Docs Storage Indexes
externalobjects 40.1M 94.5 GB 12.2 GB
subscriptionruns 4.7M 21.1 GB 1.2 GB
externalobjectdatafields 2.5M 9.0 GB 134 MB
everything else combined < 6 MB

(snapshot is 2026-03-19 — six months stale; regenerate with yarn prod:info)

There is no TTL, no archival, and no pruning anywhere. And three separate deletion paths delete a Connection while leaving all of its ExternalObjects, DataFields, Subscriptions and SubscriptionRuns behind. Even the manual cleanup script skips subscriptionruns — the 21 GB collection. How much of the 94.5 GB belongs to nothing is the single highest-value unknown here, and it is one read-only aggregation away.

subscriptionruns averages 18,815 bytes per document because each row stores the entire triggering event — the full ExternalObject including its data, plus prevData. Nothing reads runs older than the recent window. A TTL on timestamp, or simply projecting less into event, is the largest single storage win available. (Retention must exceed any realistic replay window, since the unique-partial indexes on it are dedup guards.)

Index drift: production has 9 indexes on externalobjects; the model declares 5. account_1 is a strict prefix of a compound index and is redundant; createdAt_-1 has no query in the codebase. Dropping both is low-risk and removes write amplification on 40M upserts.

The change stream is the hard dependency — replica set required, pre-images stored in a separate system collection, updateLookup issuing an extra primary read per event, and an oplog window that bounds how long the handler can be down before the checkpoint is wiped and events are silently lost. Also: 8 processes × a default maxPoolSize of 100 = up to 800 connections, which rules out shared tiers on its own.

The current Atlas tier, RAM and utilisation are not in either repo. Without those, “can it be downscaled” is unanswerable — but the levers that would make it downscalable are clear and all three are pure cleanup.

DigitalOcean — the side pursuit#

11 apps. Two are cold with no in-repo dependents:

App Branch Last commit on branch Always-on cost
dopemail-devver ai-test-ui 2026-01-15 (8 months) 3 vCPU / 4 GB
dopemail-dev-dave1 feat-DaveBlueprints 2026-01-26 (7.5 months) 3 vCPU / 4 GB

Every deployment since is DO platform maintenance, not a human deploy. Neither is referenced anywhere in either repo. ~6 vCPU / 8 GB of always-on shared instances. They share a Postgres cluster with dopemail-dev, which is documented as live — so that cluster stays.

Needs a human decision: dopemail-staging has been idle since 2026-07-08 but carries the most expensive non-production component in the fleet (apps-d-2vcpu-8gb dedicated worker) plus its own Postgres cluster and a read replica used by nothing else. Do not kill it blindly — it runs the same 2 AM scheduler as production and has placed real TecMail orders.

Also worth a look: the legacy dope360 Atlas cluster is configured on all six dopemail apps and clickrun-production, but exactly one code path reads it (ImportHistoricalAccount), and in clickrun it is set and never read at all. An entire cluster for one historical-import service. Is that import still run?

And a dead-code landmine: src/jobs/scheduleSyncJobs.js creates a job document per connection per tick with no dedup. It is never started — but if anyone ever wires it up thinking it’s the scheduler, agendaSyncJobs gains ~21,000 documents a day, unbounded. Delete it.

Raised separately, unrelated to cost: doctl apps spec get returns live production credentials in plaintext for every app — Stripe, SendGrid, AWS, Twilio, Google Maps, Mongo, Postgres, Devise JWT signing keys. They are value: entries rather than DO encrypted secrets.


Sequenced plan#

Phase 0 — Land what’s written (days, not weeks)#

Nothing here is new code.

  1. Merge feat-MatchBacksWithCRMDataCont → main. Fixes the sign bug and the sentinel-date bug, adds the recompute tool and the Pulse CRM Data tab.
  2. Run rake contacts:recompute_crm_fields on production. The migration deliberately does not backfill prod/qa — it’s an out-of-band tool, batched and throttled, with dry-run and account scoping.
  3. Fix the duplicate sign defect in DashboardSummary::REVENUE_MTD_SQL — the branch doesn’t cover it, and it drives a customer-facing tile.
  4. Fix account 5151’s jobs_config (add ::cents, move date off customer.created_at) and re-pull.

Expected outcome: reported fleet revenue moves from 832.3Mto 448.7M, and $78.9M that was locked out becomes reachable.

Phase 1 — Verify before designing#

Three checks that each change what gets built:

  • Are change-stream pre-images enabled in production? Decides whether a backfill is a perf problem or a mass-mailing incident.
  • Does contacts.tec_mail_data contain a carrier-route key? Decides whether EDDM matchback is a week or a quarter.
  • Does contacts.data_axle_data contain a real_estate block? Decides whether ATTOM appends are needed for the report at all.

Phase 2 — Correctness at fleet scale#

  1. Run the crm-customer-success skill across all 47 enabled accounts plus the ~1,100 accounts with a connection. Produces the per-account prescription and the honest reachable-dollars figure.
  2. Implement the DATA-10 job-level fallback in the trigger so the skill’s prescriptions can actually be applied.
  3. Add a plausibility guard: flag any account whose average revenue per converting contact is implausible for its industry, and any config whose fk/date/amount fails a type check. Account 5151 would have been caught on day one by “$160k per garage-door customer.”
  4. Turn HubSpot on (one line, no re-sync). Publish the honest capability matrix so sales and CS stop promising revenue on ProLine and BuilderPrime.

Phase 3 — The mapping loop#

  1. Close the write-back loop: propose → review → apply → verify, with the skill’s learning store as the prior. Add cdmMap to SubscriptionTemplate, add a templateId link, fix the ||/&& validator bug.

Phase 4 — Reach#

  1. Backfill mode: mark the write, honour it in the handler, filter server-side, add a dated fetch mode, add a third job type. Consider the interim isChangeExpired inversion first.
  2. Trigger retry, built on AutomationRun idempotency by X-Subscription-Run-Id (card #2626) — not blind retry, given the 5-second timeout.
  3. EDDM, per the Phase 1 answer.

Phase 5 — Operations#

  1. Add stalled and degraded handlers; persist a real warning status; give the customer an in-app signal; route to Slack; close the loop on recovery. Copy StuckJobsMonitor.
  2. Over-trigger detection off the 7d-vs-30d ratio that already exists, priced through AutomationPurchaseSummary.
  3. Automations UI — start with the trigger builder, which is the oldest and most inconsistent surface and the one AI mapping will need to render into anyway.

Side pursuit#

  1. Kill dopemail-devver and dopemail-dev-dave1. Size the orphaned-ExternalObject problem. Decide subscriptionruns retention. Then, and only then, ask about the Atlas tier.

The honest summary#

This is not one project. It is four, and they have different shapes:

  • A correctness project that is mostly already written and unshipped. This is the urgent one, and it is small.
  • A data-reach project (backfill, EDDM, deeper look-back) that is genuinely hard and carries a real risk of accidentally mailing people.
  • An automation/AI project that is ~70% built and needs a write-back loop to become the goal rather than an audit.
  • An operations project (alerting, monitoring, UI) that is mostly gap-filling against patterns already in the codebase.

The milestone — know what they’ll see before we show them — is now answerable, and the answer is that we should not open this to customers yet. But the gap between here and there is smaller than it looks, because the highest-value fixes are sitting on a branch nobody merged.