What already exists across both stacks, what is genuinely missing, and how lifecycle marketing gets run day to day once it is in place. Every status here was checked against a live system, a timestamped log or a git object.
Eleven lifecycle programs are sending today across four senders. They were each built
standalone, so there is no shared audience layer, no shared frequency cap and no shared
measurement. Separately, a full customer platform exists in dockblocks-data-ops with
identity resolution, consent modelling, Customer 360 and RFM, and an activation layer with a
shared eligibility gate. It gates one stack.
The whole proposal is one sentence: one question, asked by four senders, answered in one place.
Brevo carries mail the recipient expects. New burner domains carry mail that might generate complaints. The line is whether the person is warm, not whether the message is marketing.
| Traffic | Sender | Class | Capped |
|---|---|---|---|
| Order confirmation | Shopify native | Transactional | Exempt, never suppressed |
| Shipped notification | Brevo | Transactional | Exempt |
| Welcome, nurture, quote nurture | Brevo | Expected | Yes |
| Abandoned cart, checkout, browse | Brevo, to build | Marketing | Yes, needs unsubscribe |
| Rep-signed campaigns, warm lists | Brevo, three-domain split | Marketing | Yes |
| Cold and dormant reactivation | DBOS, burner domains | High risk | Yes |
| Cold prospecting | Apollo, hello@ domains | High risk, not lifecycle | Yes |
| Rep digests, ops alerts | Brevo, direct | Internal | No, different population |
An earlier draft had a single line where a quote preceded every purchase. That is wrong. Shopify is direct, and Matt is explicit that product type decides the route.
"If they select pontoons. No. If they select boats over 5,000. No... the only two are boats over 5,000 and pontoons and tritoons... And then on the floating dock side, helicopter docks, commercial, they're not going to be able to figure that out. Special event docks. I think everything else they can get in the online store." Matt West, 2026-08-04
So path is knowable at lead capture, from product interest. Today it is recorded on the sales order, which is after the only moment it is useful. That is the same thing as the action item already assigned on that call: exclude pontoons, CD switches, helicopter docks, commercial and solar from Shopify links in nurture.
| Path | Enters at | Note |
|---|---|---|
| Self-serve | Prospect | Shopify direct, no quote |
| Rep-led | Prospect | Quote via Zoho |
| Distributor / dealer | Customer | CSV upload, B2B reorder |
| Secondhand-owner parts | Customer, no prior stage | Bought the dock on eBay or Craigslist |
| Warranty / expansion | Customer | Lifetime warranty, damage or add-on |
"the main reason people need parts is they buy a dock block on the aftermarket and then they take it home. And their boat is different. So we get tons of orders. We're like, we can't find the original customer in there." Matt West, 2026-08-04, on where: "I'm guessing eBay and Craigslist"
Identity resolution is deterministic and anchored on Zoho. A secondhand owner has no Zoho anchor, so they either create a new identity each time or fail to resolve. By Matt's account this path generates "tons of orders", and he calls parts the most profitable end of the business. The highest-margin line is the one the data model serves worst.
Two fields the stage model needs and Zoho does not have. Repeat is only a tick box, and Dormant has no status at all. So the stage field has to be created, not just populated.
State-evaluated beats trigger-based. A trigger reacts to one event in isolation. State evaluation reads the person's whole current condition at the moment of acting. Scalero's example: do not fire the hour-four SMS if the hour-one email was already opened. The warehouse computes state; the senders are not consulting it at send time.
Frequency capping is a separate lever from the domain split. The split stops one reputation carrying every unsubscribe. A cap stops one person receiving too much. Braze's point applies exactly here: over-messaging comes from several senders each sending a defensible amount with no shared view of the total, so the cap has to be one cap across every sender and channel, segmented by engagement tier, transactional exempt. Scalero's published benchmark is 2 to 4 emails plus 1 to 2 SMS per week per subscriber.
Holdouts, carved at design time. An A/B test proves which variant did better. Only a true no-send control proves the program was worth running. There are no holdouts anywhere in the estate today, and they are expensive to retrofit because the counterfactual population no longer exists.
Each is a defence against the same mistake: an eligibility fact read before the moment of sending is a guess about the moment of sending. It appears four times in this estate, and it is worth designing against once rather than catching four times.
| # | Layer | Where it stands | Action |
|---|---|---|---|
| 01 | Identity and profile | Built: person_map, Customer 360 | Accept. Add a freshness check |
| 02 | Event spine | 42 events documented, snapshot pinned 2026-07-02, drift CI never built | Bridge |
| 03 | Stage model | RFM in gold. No agreed business stages | Decide. Five, above |
| 04 | Journey map | Three competing catalogs | Build. Map tab |
| 05 | Segment registry | Built in gold, not adopted by Stack A | Bridge |
| 06 | Shared frequency cap | Does not exist | Build. Blocking |
| 07 | Shared suppression | Four stores, one reconciler in report-only mode | Build. Blocking |
| 08 | Content and templates | Stack A only, recovery flows unbranded | Extend |
| 09 | Measurement and holdouts | Rill exists, no holdouts, ROAS blind | Build |
| 10 | Write-back to Zoho | DBOS ledger is Postgres only | Build |
| 11 | Cadence and audit | Does not exist | Adopt |
| 12 | Doc reconciliation | Three claimants, ~25 unmarked files | Build first |
Six of twelve are accept or bridge. The build list is four items and two of them gate everything else.
Stage is relationship depth. Path is how someone transacts, and it is a separate dimension because three of the five paths never pass through Prospect at all.
This is how people agree coverage, ownership and gaps. It is not the execution model. Execution is rule-based and evaluates state at send time, so do not read the columns as a sequence anybody walks. The runtime lives in the Rules tab.
Two nodes are marked as costing something today rather than merely missing: Apollo cold prospecting at 6.5% hard bounce from the root domain, and the abandoned-cart flow running unbranded out of Shopify.
This is the half the coverage map deliberately cannot show. Every program is an eligibility question evaluated at send time, not a step on a path.
Eligibility is a property of the person: may this person be contacted at all, on this channel, right now, given everything that already touched them. One answer, so one owner: the gold layer, because it is the only component that sees all four senders plus Zoho plus Shopify. Enrollment is a property of the program and stays with whichever engine runs it. This asks for an interface, not for control of anybody's engine.
| Program | Entry condition | Exit | Class | Holdout |
|---|---|---|---|---|
| Welcome | Lead created | Sequence complete or quote requested | Expected | No, and should not |
| Product nurture | Product interest set, path self-serve | Order, or quote requested | Expected | Yes |
| Quote nurture | Quote issued, no order | Order, or quote aged out | Expected | Yes |
| Abandoned cart | Cart abandoned, no order in window | Order placed | Marketing | Yes |
| Stale quote | Quote aged past threshold, no movement | Order, or moves to Dormant | Marketing | Yes |
| Rep campaign | Segment from gold, warm only | Per send | Marketing | Yes |
| Win-back | Dormant, 365 days no order and no engagement | Any order or engagement | High risk | Required |
| Reconversion | Returning person detected | One send per person per 90 days | High risk | Required |
| Cold prospecting | Apollo filter, verified email | Reply, bounce or optout | High risk, not lifecycle | No |
| Order confirmation | Order placed | Sent | Transactional | Never |
Every enrollment answers these before anything sends. Order matters: the cheapest and most absolute refusals come first.
1 suppressed? one shared store. Unsubscribe, hard bounce, complaint, manual.
Missing store is a HARD FAILURE, empty store is a BUILD FAILURE.
2 consent? opt-in, not not-opted-out. Per channel.
3 transactional? if yes, stop here and send. Never capped, never suppressed.
4 frequency? ONE cap across every sender and channel, by engagement tier.
benchmark 2-4 email + 1-2 SMS per week per person.
5 temperature? computed from gold. A warm person must not be on a burner domain,
and a cold one must not be on a protected domain.
6 priority? if two programs both qualify, the higher-intent one wins and the
other defers rather than both sending.
7 holdout? if the person is in the control group, record the suppression as
a measurement event, not as a failure.
on gold unreachable: marketing FAILS CLOSED. transactional is unaffected, which is the
other reason order confirmation stays on Shopify native.
Eligibility depends on facts no sender can see. Whether someone is over their weekly cap depends on what the other senders sent. Whether an audience is genuinely dormant depends on order history the sender does not hold. A sender asking itself "may I send this?" can only answer from its own records, which is exactly how the failures in the Findings tab happened.
A guard can prove a send left from a burner domain. It cannot prove the audience belonged there. Nothing in a payload distinguishes a correctly-routed dormant list from a warm list misrouted off Brevo. So temperature and path are computed attributes from gold, asserted at enrollment, never a label someone types into a campaign builder.
path := f(product_interest) set at LEAD CAPTURE, carried forward pontoons, boats over 5,000 lb, tritoons -> rep-led, no Shopify link in nurture CD switches -> rep-led, no Shopify link helicopter, commercial, solar, special -> rep-led, no Shopify link everything else -> self-serve, Shopify link from email 2 or 3 distributor account flag -> distributor parts order, no prior record -> secondhand owner existing customer + warranty or add-on -> warranty / expansion Today this lives on the SALES ORDER, which is after the only moment it is useful.
Found while mapping every sending path on 2026-09-24, recorded with paths, values and dates so each can be checked. Ordered by urgency.
bounce_watchdog threshold 2.0% hard bounce | 25 sequences
breaching 2 ok 6 not measurable 17
BREACHING
6.5% 25/382 ACTIVE Mike Intro Sequence
2.3% 13/577 ACTIVE Jim Intro Sequence
apollo_bounce_watchdog.json every entry: "last_alert": "2026-08-28T17:56:06Z"
detected, alerted once, silent for 27 days since
both send FROM matt@dock-blocks.com the root domain, not a subdomain
The root domain carries Matt's live Proofpoint mail, the Shopify transactional flow, and is the parent of one of the three protected Brevo sending domains. Apollo sequences pause only in the UI; there is no API kill switch. This is a larger active threat than anything the burner-domain plan addresses and it is fixable in minutes.
dock-blocks.com DMARC v=DMARC1; p=quarantine; adkim=r; aspf=r relaxed alignment MX mx1/2/3-usg2.ppe-hosted.com Proofpoint, Matt's live mail DKIM brevo1 / brevo2 present
Relaxed alignment means a DKIM signature from any subdomain aligns to the
organizational domain. So sequences.dock-blocks.com, the documented unsubscribe
route for the DBOS engine, does not sit near the root's reputation. It authenticates as it.
The pattern to copy already exists. Five separate registrable domains are in use for Apollo, each authenticated, with a warm-up ramp codified at 10/20/30/50 per mailbox per day and assignment fixed per industry so bounce stays attributable to one list.
dock-blocksonline.com SPF ok MX Outlook DMARC p=quarantine Jim: waterfront, hospitality dock-blocksdirect.com SPF ok MX Outlook DMARC p=quarantine Mike: boat owners, dealers dock-blocksweb.com SPF ok MX Proofpnt DMARC p=quarantine Mark: commercial, municipal dock-blocksgov.com SPF ok MX Outlook DMARC p=quarantine marine law enforcement dock-blockspro.com reserved-mailbox system, 2026-09-16, built not active
Recommendation is still to buy two new domains for reactivation rather than reuse these, since the Apollo five carry a working revenue engine. Copy the pattern, not the domains.
suppression_list.csv 16,299 addresses built 2026-08-11 read by apollo/enroll.py global-suppression.csv 3,221 addresses built 2026-09-07 read by the live Brevo/Zoho path in global but NOT in Apollo's list: 1,960 nothing schedules .claude/scripts/build_suppression_list.py
Neither file is a superset of the other. The gate design is the best in the estate: multi-source, missing file is a hard failure, empty file is a build failure, fails closed. The input is stale.
And the evergreen loop does not consult it at all. The auto-prospecting workflows are Apollo-native, proven read-only to our API key. Live filters on all three active loops reference only Apollo-internal checks plus verified email status. A hard bounce is excluded; a consent withdrawal elsewhere is invisible.
16 rule_configs, 6 active, 3 are prospecting loops, all ran 2026-09-24: Mark Government created 2026-08-27 10/day last ran 14:22Z Mike Intro created 2026-08-24 10/day last ran 13:11Z Jim Intro created 2026-03-31 10/day last ran 14:22Z
com.dockblocks.suppression-reconcile loaded, 07:00 daily log written 2026-09-24 08:23, 30KB final line: "REPORT ONLY. 19 target(s). Add --apply to write (ledger mode)." "14 of them are still members of a sending list. Removing them is a separate decision and is not done here." [redacted]@gmail.com emailBlacklisted STILL IN LIST(S) [29, 56, 59, 64, 177, 178, 220] [redacted]@verizon.net emailBlacklisted STILL IN LIST(S) [59, 63, 137, 154, 162, 168, ...]
Brevo's own blocklist should stop marketing reaching these people, so this is a record-integrity
problem rather than a proven send. The exception matters:
Brevo's emailBlacklisted does not block transactional, proven on
2026-08-25 when a blacklisted address received, opened and clicked. That is why abandoned cart
must not be built on the transactional endpoint for convenience.
"Leads are down maybe 20%, but revenue is down 50%." Matt West, 2026-09-22. 158 orders against 468 a year earlier, ad spend flat
"our coverage has increased from as low as 0 to at least we're able to attribute at least 30% of all the forms... We need to get this number to over 90." Kinyanjui Njoroge, 2026-08-12
The gap register already ranked gclid and UTM capture the highest-ROI item. It now has a business consequence attached, and it has been open across five months with no owner. The funnel Njui names in the same passage is the real object model: click, visit, lead, deal or quote, sales order. Also recorded: 500 to 600 PPC-sourced phone numbers never logged in Zoho.
| Document | Dated | Standing |
|---|---|---|
docs/workflows/LIFECYCLE_COMMS_MAP.md | 2026-08-26 | Most current. Gap register G1 to G19. Has self-corrected three times |
docs/architecture/eventcatalog-catalog-as-built.md | 2026-07-07 | Pinned 2026-07-02, drift CI never built. Do not cite on reconversion |
docs/lifecycle_comms/lifecycle_comms_catalog.json | 2026-09-23 | 62 rows, in data-ops |
The sharpest conflict: the EventCatalog declared reconversion live on 2026-07-07 and was never revisited. Sending did not run until 2026-08-15, because the flag was set in Dokploy but never listed in the compose file, and Compose injects only yaml-referenced variables. Detection ran throughout. Sending never did.
lc-52 quoteFollowUp is marked LIVE in the data-ops catalog while a sensor reading
live Zoho last_executed_time lists it among nine rules switched on that have never
fired once.
The 99-path comms register is not missing, it is stranded. It lives only on branch
docs/lifecycle-master-consolidation, last commit 2026-07-07, 196 rows. Read it with
git show docs/lifecycle-master-consolidation:docs/workflows/comms-paths-inventory-and-test-matrix.md.
Do not merge that branch to recover it: it is 14 commits ahead while main is 1,766 ahead and
would add 7,922 lines, mostly superseded files.
Three structural reasons: the ledger does not record which population a message reached, Zoho-native rules produce no ledger row at all, and Shopify's native sends are invisible from the repo. The cap cannot be verified until population is a recorded field. The clearest example: the Apollo click webhook, in one call, queues an external SMS to the lead and writes the field that fires an internal notification to the rep plus two hardcoded addresses. One call, both populations, one log line.
bf08a612 the domain-split check gated on /emailCampaigns BEFORE looking at senders, so
it would print success having examined zero files. Added a denominator floor.
0d1bc1ea the one-door guard needed "brevo.com/v3" and the send path on ONE line, so a
file holding the base in a constant was a second door for as long as the guard
existed. Baseline 8 to 11, all pre-existing internal digests, no customer mail.
Neither exposed a customer. Both are in the suite, which now runs 204 checks.
| Concern | Owner | Note |
|---|---|---|
| Customer platform, gold, eligibility gate | Njui | Owns it because he owns the layer that can compute it |
| Apollo outbound engine | PivotPlanIt | 33 scripts. Unchanged but for one pre-enrollment check |
| Brevo execution, templates, automations | PivotPlanIt | Automations are hand-built in the UI from versioned specs |
| Approval for anything external | Alecia | Including removing shadow mode |
| Stage model and journey map | Shared, Alecia decides | One model, everything tags against it |
"Built" is not a state anybody can act on. Anything above built must name a re-runnable sensor.
| State | Means | Sensor |
|---|---|---|
| scoped | Trigger, audience, goal and exit written down | The spec exists |
| built | Code or automation exists, tests pass | Test in the suite |
| deployed | Running where it will run, not on a laptop | Container or job |
| scheduled | Fires on its own. A disabled job is not scheduled | Schedule plus a recent log |
| sending | A real message reached a real person | Ledger row, not an HTTP 200 |
| measured | Enrolled, delivered, engaged, converted, unsubscribed, with a holdout | Dashboard panel |
A 200 means the request was understood, not that it happened: read the outcome, not the status. And zero findings over zero input is the same document as a clean estate, so any check that can report no problems having examined nothing must fail instead.
| When | What | Output |
|---|---|---|
| Weekly | Per-program performance. Enrollment queue. Gate and deliverability check against thresholds. Anomaly check on anything that moved without a change | Short written read, nothing if nothing moved |
| Per launch | Before: audience verified and dated, every variant reviewed by eye, suppression fresh, holdout carved, cap checked. After, at 24h and 7d: delivery, complaints, unsubscribes, replies, conversions | Launch record in the program folder |
| Monthly | Frequency cap review, since volume and engagement tiers move. Stage boundary check | Cap changes, if any |
| Quarterly | The audit: journey map, content per stage, automation performance, churn and win-back, quantitative plus qualitative, prioritised plan with owners | The audit document |
| Event-triggered | After peak season, a launch, a reputation change or any suspension warning | Off-cycle note |
On the Labor Day send, six defects passed every automated check and were caught by eye: a Memorial Day photograph, a duplicate headline, a debug chip, an unsigned body, a signature in the wrong rep's block, and a product row below the footer. Automated checks catch shape. A person catches sense.
| Phase | Work | Gate to the next |
|---|---|---|
| 0 | One canonical map from the three catalogs plus the stranded 196-row register. Archive the rest | No competing claimants |
| 1 | Shared suppression, shared cap, agreed stage model. Refresh the Apollo suppression input and schedule it | Unsubscribe a seed in one stack, prove another refuses it |
| 2 | Burner domains registered, authenticated, warmed on a seed. Dormant win-back as pilot, holdout carved at the start | Shadow mode comes off only after the Phase 1 test passes |
| 3 | Brevo audiences come from gold. Abandoned cart, checkout and browse move off Shopify native | Shopify off and Brevo on in the same change, or customers get both or neither |
| 4 | Per-program reporting across both stacks. Holdout lift. gclid and UTM capture | The audit produces a plan somebody acts on |
Phase 2 can buy domains and warm a seed in parallel with Phase 1, since that is safe and takes 5 to 7 days. Nothing leaves shadow mode early.
sequences.dock-blocks.com inherits the root domain's reputation through
adkim=r. Is that route load-bearing, or can it move before anything sends?suppress_contact.py? Two writers with no contract is how the 7.82%
Zoho-versus-Brevo divergence happened the first time.lc-52 quoteFollowUp is LIVE in the catalog and has never fired.scripts/lib/brevo_send.py the one door, eight refusal conditions scripts/brevo/sending_domains.py three-domain split, keyed on rep scripts/brevo/suppression_reconcile.py cross-stack reconciler, report-only scripts/lead-pipeline/suppress_contact.py writes Brevo + Zoho + local ledger .claude/scripts/build_suppression_list.py builds the Apollo input, unscheduled scripts/apollo/senders.py mailbox and domain policy, weaning the primary scripts/apollo/enroll.py manual enrollment, gates on suppression, fails closed scripts/apollo/prospecting_workflow.py evergreen rule_configs, read-only to our key scripts/apollo/bounce_watchdog.py the 2% kill rule scripts/monitoring/gate_integrity.py asserts the sending plan daily services/dbos-workflows/ DBOS engine, data-ops origin/main
utm_campaign from the campaign name, so the name
is the analytics key and must be the slug.skipped_contact_ids: a 200 is not an
enrolment.rule_configs live on app.apollo.io, behind
Cloudflare, and the collection is a POST to /rule_configs/search.Research: scalero.io blog and services pages, braze.com resources and documentation, 18 URLs
recorded. Transcripts: docs/meetings/transcripts/, pulled from Fireflies this
session. Note the Fireflies titles are unreliable, because the same Zoom link is reused, so a
recording titled "Digital Marketing Call" often holds a rep one-to-one instead.