📡 Bright Side Plumbing · Apr 10, 2026 · Overnight Debrief

We Found the Leak in Google Ads

For weeks, Google Ads has been valuing every Bright Side phone call at just $1 instead of the real job value of $150 to $11,000. It was a silent leak. Smart Bidding was starved for real revenue signal, so it chased the wrong customers. Last night we found three bugs, fixed seven things, and built the plan to finally show Google what a real Bright Side job is worth. Monday afternoon we prove the pipe is clean. This document explains what broke, what we fixed, and what it means for every person on the team in plain English.

🧱

Foundations — The Ground Rules Before We Start

READ THIS FIRST

Before we get into the fixes and the Monday plan, there are eight foundational concepts that the rest of this document assumes you already understand. If you're not a marketing nerd, you probably don't. That's on us, not on you. This section fills those gaps so the rest of the doc actually lands. Read it even if you think you already know this stuff. There are details in here that even experienced marketers get wrong.

🎯
Foundation #1
How Google Ads actually makes money for Bright Side

Bright Side pays Google a monthly ad budget. Google uses that money to put BSP ads in front of people searching for "plumber near me" or "sewer repair Overland Park" on their phones. Some of those people tap the ad and call BSP. Ashton or Jordan answers. The customer books a job. A plumber arrives. Revenue. That's the whole loop. The question is: which ads did Google put in front of which people? That decision is made by Google's AI, not by us. And that AI needs real data to make good decisions.

💰 The Bright Side Revenue Flywheel
💵
SPEND
BSP pays Google a monthly budget
👀
IMPRESSIONS
Ads shown to people searching
📞
CALLS
Customer taps "Call" in the ad
🔧
JOBS
Ashton books. Plumber arrives.
💰
REVENUE
Invoice. Paid. Closed.
The critical step is the last arrow. Google doesn't automatically know a customer call turned into a real paid job. We have to TELL Google. That telling step is called "attribution" and it's where the 21-day silent bug was hiding.
🎟️
Foundation #2
What a "conversion" is (and why the number matters)

In Google Ads world, a "conversion" is any event we declare as "this is what success looks like." For Bright Side, success is a real customer calling, booking a job, and paying for it. A conversion has two important pieces: the event itself (a phone call happened) and the dollar value (that call turned into a $7,082 sewer job). Most people only know about the first piece. The second piece is what everything last night was really about.

🔢
COUNT — how many conversions
"We got 13 phone calls this week." That's a count. Google uses it to track activity. But a count alone tells Google nothing about whether any of those calls were worth it. 13 calls could be 13 million-dollar sewer replacements or 13 wrong numbers.
Count is like counting the number of fish you caught. It's a stat. It doesn't tell you if you kept any.
💰
VALUE — how much each one was worth
"13 calls this week, average $7,082 each, total $92,066." That's value. This is what Google's AI uses to decide which ads to run MORE of next week. Without value data, the AI can't tell the difference between a $50 service call and a $15,000 sewer replacement.
Value is counting the weight and grade of your keeper fish. That's what tells you if the trip was profitable.
⚡ The whole bug in one sentence
Google Ads was counting calls correctly (we got 13 per week) but valuing them all at $1 each. So the AI saw "$13 per week of call revenue" instead of "$92,066 per week of call revenue." The AI responded rationally to the bad numbers. It treated BSP like a $13-per-week business.
🤖
Foundation #3
Smart Bidding — the Google AI that is making the decisions

Smart Bidding is the name of Google's AI that decides, in real time, which search terms BSP's ad should appear for and how much to pay for each one. It runs every auction, every minute, 24 hours a day. It is NOT a human making these decisions. It is a machine learning model that gets smarter the more conversion data you feed it. The more accurate that data, the smarter the AI gets. Garbage in, garbage out. This is the system that was being fed $1 per call for 21 days.

🔁 The Smart Bidding Learning Flywheel — why data quality matters
✅ GOOD DATA loop
1. Real job values ($7K to $11K) feed Smart Bidding
2. AI learns "sewer emergency in Overland Park = big money"
3. AI bids more aggressively on those specific searches
4. BSP wins more high-value customers
5. More real job values feed back in
6. AI gets even smarter
→ CPL drops, revenue climbs, flywheel accelerates
❌ BAD DATA loop (what we were in)
1. Fake $1 values feed Smart Bidding
2. AI learns "these calls are nearly worthless"
3. AI bids low on everything, chases any cheap click
4. BSP wastes budget on bad-fit customers
5. More fake $1 data feeds back in
6. AI gets more confident about being wrong
→ CPL stays high, revenue stagnates, spiral tightens
🎓 What "Smart Bidding learning phase" actually means
When you make a big change to what Smart Bidding pays attention to, the AI enters a "learning phase" for 7 to 14 days. During that time, it's recalibrating based on new data. If you change things mid-learning, the clock resets and the AI stays confused longer. That's why Kalen's "hands off until April 26" rule exists.

What triggers a reset vs what does not:
DOES NOT reset the clock: adding or removing negative keywords, adding regular keywords, pausing keywords, ad copy changes, asset extensions, minor budget tweaks, landing page URLs, audience tweaks. These are normal optimization knobs.
DOES reset the clock: switching bid strategies, changing target CPA/ROAS by more than about 20%, significant budget shifts, and changing the primary conversion goal set — adding or removing actions that Smart Bidding treats as the optimization target.

What this means for BSP as of tonight: The Apr 10 fixes (7 call asset repointings from ghost 179 to primary 7476805672) were CONFIG fixes that did NOT reset anything. The Apr 11 evening work DID add and remove primary goals: we created "ST Call Completed (API)" as a new primary, and we demoted a duplicate Website Contact Form plus a dead EC4L customer match action. That counts as a goal composition change, and Sewer specifically entered LEARNING_COMPOSITION_CHANGE as a result (Brand and Emergency did not — likely because Sewer has the richest conversion history, so it's the one campaign with enough data for Google to run a meaningful re-learn on). This is intentional and expected: the clean goal set after re-learning produces better CPL than the polluted set Smart Bidding was previously optimizing against. During the 7-14 day window, do NOT touch Sewer bids, budgets, or targeting.
🔗
Foundation #4
Attribution — connecting the ad click to the paid job

"Attribution" is the industry word for "proving that this specific ad click caused this specific paid job." It sounds simple. It is not. Google knows when someone taps a BSP ad. ServiceTitan knows when a plumber completes a job. But neither system automatically knows those two events are the same customer. Someone has to draw the line between them and tell both sides. That "someone" is a piece of software we call a pipeline. The pipeline's only job is to walk across that gap every night and hand Google the receipts. Our pipeline was broken. That's what we fixed.

🌉 The Attribution Gap (and why we need a pipeline)
🏪
GOOGLE ADS KNOWS
• Who clicked an ad
• When they clicked
• Which ad
• Which GFN they called
• Caller's area code
THE
GAP
🔧
SERVICETITAN KNOWS
• Which customer called
• What job they booked
• Which plumber completed it
• What it cost
• Whether they paid
Both systems have half the story. Neither knows the full loop. The pipeline's only job is to bridge the gap. It reads from one system, matches events to the other, then tells Google "hey, that click from Monday turned into a $7,082 job on Wednesday." That is attribution.
🚚
Foundation #5
What a "pipeline" is — a little program that runs in the background

"Pipeline" is a software word for "a little program that runs in the background on a schedule and moves data from one place to another." Think of it as a daily delivery truck. Every morning at 6 AM, Bright Side's attribution pipeline wakes up, grabs yesterday's completed jobs from ServiceTitan, finds the matching ad clicks from Google, stuffs them into a file, and hands them to Google's server with a note that says "these are all the real jobs we got from your ads yesterday. Credit them correctly." It runs every day, unattended, nobody has to watch it. That's what makes pipelines powerful AND dangerous. When they break, they break silently unless you're actively checking. That's exactly how our bug hid for 21 days.

💸
Foundation #6
The cost of the 21-day silent bug — in real dollars

Here is the financial math. This is the number Stephanie cares most about. It is conservative. It uses only the data we can verify right now. It does not include the compounding effect of Smart Bidding getting smarter over time.

What Smart Bidding saw
$13/wk
13 calls × $1 fake value
What actually happened
$92K/wk
13 calls × $7,082 real avg
Signal gap per week
$91,987
Real revenue Google never knew about
Multiply by 3 weeks (the known silent period):
~$275,961 of real revenue signal Smart Bidding never saw
This is data loss, not revenue loss. The revenue still happened. But Google's AI didn't learn from it, which means every future ad dollar was spent less efficiently than it should have been.
💡 Why "data loss" is still expensive
Smart Bidding doesn't just need data. It needs REPRESENTATIVE data to get smarter over time. Three weeks of $1 values taught it that BSP calls aren't valuable. That lesson has a compounding cost — every auction Smart Bidding enters going forward will be influenced by what it learned during that window. Fixing the data going forward doesn't undo the bad learning. It starts correcting it, slowly, over the next 7 to 14 days. That's why tonight's fixes are urgent AND why we can't expect instant CPL drops. The AI has to unlearn before it can relearn.
🌐
Foundation #7
Online vs Offline conversions — why BSP is mostly offline

There are two kinds of conversions in Google Ads world. Knowing which is which is important because they work completely differently.

🌐
ONLINE conversions
When a customer does something on the website that proves they converted — like completing a form or reaching a thank-you page. Google tracks these automatically through a snippet of code on the page. Easy, instant, reliable.
BSP example: someone fills out the contact form on callbrightside.com/contact. Google knows immediately.
📞
OFFLINE conversions
When the actual paid result happens outside the website — on a phone call, in person, or days later when the job closes in ServiceTitan. Google can't track these automatically. We have to upload them manually (via the pipeline) and tell Google "this call became a $7K job."
BSP example: the entire phone-based business. 90% of real BSP revenue is this.
⚡ Why this matters for Bright Side specifically
BSP is overwhelmingly a PHONE business. Stephanie sees it in the data every week (220+ calls per week vs a handful of form submits). That means BSP lives and dies on offline conversion tracking. If the offline pipeline is broken, most of the business is invisible to Google Ads. That's exactly what last night's bug did — it made 90% of BSP's real revenue invisible to the AI that decides where to spend the ad budget.
🔌
Foundation #8
What an "API" is — the invisible machine-to-machine phone line

An "API" is just a word for "the phone line two pieces of software use to talk to each other." When the pipeline wakes up at 6 AM and tells Google "here are yesterday's jobs," it's calling Google's API. When the pipeline reads data from ServiceTitan, it's calling ServiceTitan's API. APIs are the invisible plumbing that lets different companies' software talk to each other without humans copying and pasting. For Bright Side, three APIs matter: ServiceTitan's API (reads jobs out), Google Ads API (sends conversions in), and 3CX's API (reads phone call records). When last night's diagnostic sent test events to Google, it was calling Google's API five separate times with five different fake events. Google's API sent back five separate answers. That's how APIs work. Machines talking to machines with protocol instead of words.

🎯
The 8-sentence summary of everything above
1. BSP pays Google to show ads, which generate phone calls, which become paid jobs.
2. Google's AI (Smart Bidding) decides which ads to run, and it only gets smarter if we feed it real conversion data with real dollar values.
3. A "conversion" has two parts — the event (a call happened) and the value (it became a $7K job). Value matters more than count.
4. Google knows the ad click side. ServiceTitan knows the paid job side. A "pipeline" is the little program that walks between the two and tells Google "this click became this job."
5. Our pipeline was broken for 21 days. It was sending $1 values instead of $7K values, and also rejecting 40% of events silently because of a clock bug.
6. Smart Bidding saw $13 per week in call signal when the real number was $92,000. It responded by treating BSP like a $13-per-week business.
7. Fixing it last night means Google now sees the real revenue, the AI will retrain over 7-14 days, and CPL should drop 30 to 50 percent.
8. Monday at 2 PM we prove the next phase of the build (matching calls to jobs via Google's own log) is working before we enable it for real.
🎯

The Bottom Line

START HERE

Here are the four numbers that tell the whole story. Each one is real, measured, and already in motion. Read them in order and the rest of the document is bonus context.

📞
$150
Real Call Value
Was $1. Now $150 to $11K.
7
Fixes Live Last Night
Every ad call now tracks right
🔍
3
Silent Bugs Found
Hiding in plain sight 21 days
📅
2 PM
Monday Proof Check
Robert reports the numbers
🎯 Translation for the team
Google Ads is the fishing pole. Smart Bidding is the AI that decides where to cast the line. For weeks we were telling Smart Bidding that every fish we catch is worth $1, when really our sewer jobs are worth $7,000 and up. Smart Bidding responded the only way it knew how. It went after cheap, loud, low-value fish. Last night we taught it the truth. Monday we catch the first batch of real fish with the corrected pole.
🎣

Kalen's Fishing Story, Continued

PLAIN ENGLISH

Kalen explained Google Ads at the last meeting using a fishing analogy. We are paying for bait, watching nibbles, counting fish. That story was right but half the fish we caught were being counted as minnows worth a dollar. Here is what the chain looked like before we fixed it, and what it looks like now.

❌ BEFORE — the broken chain
🎣
$800
Bait we spent
🐟
20
Real phone calls
📞
$1
Each marked as
🤖
Lies
Smart Bidding learned
✅ AFTER — the chain is fixed
🎣
$800
Same bait
🐟
20
Same calls
💰
$150+
Real value each
🧠
Truth
Smart Bidding learns

Same bait, same number of fish caught, but Google now knows each fish is worth 150 times what it thought. In 7 to 14 days, Smart Bidding finishes its new training and starts casting where the big sewer fish swim. Expected result within 2 weeks of clean signal: 30 to 50 percent lower cost per lead on the same ad spend.

👻

The Phantom Numbers RCA — What Kalen Caught Saturday Night

LATE-NIGHT FIX

Saturday evening Apr 11 Kalen pushed back on a "47 bookings" number on the Monday standup template. His instinct: "47 for under $1K spend is impossible — 10 to 20 is realistic." He was right. Real ad-sourced bookings from ServiceTitan in the same window: 11. The 47 was a phantom — it came from Google's own all_conversions column which still counts 123 widget clicks from the old "Book Appointment" action that Nikhil and Evelyn demoted on Apr 2. The demotion removed the action from the main conversions column but it still appears in all_conversions, and every tool pulling that column shows inflated numbers. → See GCLID Implementation Cheatsheet

That moment triggered a root cause analysis of the entire phantom numbers problem. We found 10 layered causes that explain every weird number we've seen this month, and designed 5 structural fixes that eliminate them at the source. By Saturday midnight, 5 of 5 fixes are live on the VM. Sunday morning you can read numbers on any BSP dashboard and know exactly where they came from in under 5 seconds.

🎯 THE ONE-LINE ROOT CAUSE
Every phantom number traces to the same root: no canonical definition of what a metric IS, and no receipt showing where the number came FROM. The fix is to write both down, once, in one place, and make every tool read from that place.

📋 The 10 Root Causes at a Glance

#1 · HIGH BLAST
Google all_conversions includes ghosts
Demoted "Book Appointment" still counts 123 phantom widget clicks at $57,508 value.
#2 · HIGH BLAST
No single source of truth for "a booking"
5 systems give 5 different numbers for the same 7-day window.
#3 · MEDIUM
Hand-edited snapshots decay
Numbers written into HTMLs stay frozen while reality moves.
#4 · MEDIUM
No receipts on displayed numbers
Orphaned asterisks. No footnotes. No source citations.
#5 · MEDIUM
Ambiguous direction on call counts
"Jordan 89 calls" — inbound? outbound? both? No one knew.
#6 · MEDIUM
Timezone boundary slop
3CX UTC, Google Ads Central, Postgres mixed. Same bug class as Apr 10.
#7 · LOW-MED
No divergence alerting
5 sources disagree and nothing fires. Same failure mode as Apr 10 silent rejections.
#8 · LOW-MED
Short-window instability
"Last 7 days" at different minutes returns different numbers. Not reproducible.
#9 · LOW-MED
Metric name overloading
"Bookings" means 5 different things in 5 different places.
#10 · LOW
No regression tests on numbers
If a query silently breaks or a column renames, no alarm fires.

🛠️ The 5 Structural Fixes — All Live Tonight

FIX #1 · METRICS REGISTRY · SHIPPED
/opt/nexus/titan/metrics_registry.yaml · 15+ metrics, 5 source adapters
Single canonical file defining every metric. Google Ads API, ServiceTitan Postgres, 3CX SQLite, QuickBooks, Ramp, and JSON file adapters all implemented. Triple-checked: 8 of 8 independent cross-verification tests pass. Ghost conversion action IDs baked into a blocklist. all_conversions banned as a display metric.
FIX #2 · RECEIPT RULE TEMPLATE ENGINE · SHIPPED
/opt/nexus/titan/metrics_template.py · Jinja + scanner
Every template variable must come through m('metric_id'). Scanner walks the rendered output and fails the build if any naked number survives. Monday 2 PM report already rendered through this engine with zero violations in strict mode.
FIX #3 · GAQL SAFE HELPER · SHIPPED
/opt/nexus/titan/gaql_safe.py · 7 smoke tests passing
Every Google Ads query now goes through a validator that blocks all_conversions, rejects queries referencing ghost action IDs (179, 881920526, 7537150978), and warns on unfiltered conversion_action FROM clauses.
FIX #4 · CROSS-SOURCE RECONCILER · SHIPPED (runs every 15 min)
/opt/nexus/titan/cross_source_reconciler.py · systemd timer
Pulls every revenue and booking metric from every available source, computes pairwise divergence, and fires a WARNING or CRITICAL into anomaly_log.json if any pair exceeds its tolerance. Catches the next phantom within 15 minutes of it appearing. Current overall status: OK.
FIX #5 · LIVE-REGEN SACRED HTML (partial) · SHIPPED
/opt/nexus/titan/sacred_fishing_regen.py · 5-min systemd timer
The fishing diagram that started all this now regenerates every 5 minutes from live registry queries. Between <!--FISHING_START--> and <!--FISHING_END--> sentinels. Hand edits to that block get overwritten. Rest of the Sacred HTML conversion is scoped for a future session.

❌ Queue items cancelled based on this RCA

CANCELLED
Fix nexus_offline_conversions.py proto3 + consent + branching
EC4L is dead for BSP. The Apr 10 cross-account collision is fatal for local home services. Script archived with deprecation header, systemd timer stopped and disabled. The date buffer patch from Apr 10 stays in place as the last touch. Replaced by nexus_uploadcallconversions.py for Monday.
CANCELLED
Deploy Google Ads User-Provided Data tag via GTM
UPD tag feeds EC4L with hashed email/phone. EC4L is dead. UPD has no target, adds a GDPR surface, zero business value. Web form attribution still handled through Snippet #55 + ST GCLID custom field (Tasks #15 and #16 in the queue).
🎯 What this means for Monday 2 PM
The 5-metric standup report Robert presents Monday at 2 PM renders entirely through the new Metrics Registry. Every number on screen carries a source receipt. Zero hand-typed numbers. If any metric source breaks between now and Monday, the template shows a warning instead of a phantom. The fishing diagram on Sacred HTML v2 regenerates live every 5 minutes. The next time anyone asks "where did that number come from?" the answer is visible inline next to the number itself. This is the trust rebuild.
📅

The 21-Day Timeline — "I Thought We Fixed This"

KALEN'S QUESTION

Kalen asked a fair question: "I thought we had fixed this a couple weeks ago." He's right. Something was fixed. That fix is still holding. The problem is it was 1 of 4 bugs, not 1 of 1. Here is the full sequence, so the team can see what got caught, what stayed hidden, and when each bug was killed.

💡 THE ONE-SENTENCE ANSWER
Nikhil and Evelyn's April 2 fix killed the first bug. Last night's work killed the three other bugs that were hiding behind it. All four needed to die before Smart Bidding could learn from clean data.
🔴
MAR 21 · DAY ZERO
🎯 Smart Bidding Launches on Dirty Data
The new Google Ads campaigns went live with Smart Bidding turned on. At that exact moment, four separate attribution bugs were already live in the account. Nobody knew. Smart Bidding starts learning from day one — and day one, the signal it was learning from was garbage.
💸
MAR 21 → APR 2 · 12 DAYS OF SILENT BLEEDING
🩸 Smart Bidding Learns "BSP Leads Are Worth $1"
Every fake "Book Appointment" widget-click fires a $1 conversion. Google happily records thousands of these. Smart Bidding adjusts. Cheap clicks win. Real sewer customers lose. CPL climbs. The dashboard still looks busy because the conversion count goes up — but the conversion value is fake. No error. No red flag. No alert.
APR 2 · FIX #1 OF 4 — NIKHIL & EVELYN
🛠️ "Book Appointment" Demoted
Nikhil and Evelyn audit the account. They find the "Book Appointment" action is counting every widget click as a lead. They demote it from Primary to Secondary. Google stops counting widget clicks as conversions. This is the fix Kalen is remembering. It was real. It held. It is still in place today.
Status today: Still demoted. Still not counting widget junk. Bug #1 is dead.
👻
APR 2 → APR 10 · 8 DAYS OF INVISIBLE BUGS
🫥 Three Silent Bugs Keep Bleeding
The front door is now clean. But behind the door, three other bugs are still live — and none of them show up in the Google Ads UI, in the weekly report, or in any alert. They are invisible unless you API-audit the raw asset pointers, parse the upload logs for silent rejections, and query Google's diagnostic endpoint directly.
🎯 BUG #2 — WRONG BUCKET
Customer-level call action + 6 call assets still pointed at a $1 ghost action (179). Every real ad call logged at $1 instead of $150 to $11K.
⏰ BUG #3 — UTC CLOCK MISLABEL
Pipeline stamped times in UTC but labeled them Central. Google saw ~40% of uploads as "in the future" and silently dropped them with LATER_THAN_MAXIMUM_DATE.
🌐 BUG #4 — EC4L CROSS-ACCOUNT
Enhanced Conversions for Leads matched hashed customers against Google's global graph. For local services it returned INVALID_CUSTOMER_FOR_CLICK 100% of the time. EC4L is dead for BSP.
⚠️ Why they stayed hidden: The upload pipeline reported "uploaded successfully" on our side, while Google rejected them server-side. No local error. No Slack alert. Only a GAQL query against offline_conversion_upload_conversion_action_summary reveals the rejection count.
🔬
APR 10 EVENING · THE FORENSIC AUDIT
🧪 5-Variation Diagnostic Runs
Robert runs a controlled 5-event diagnostic upload with partial_failure=true enabled. Google returns 5 distinct error codes — one per variation. Each error code fingerprints a different bug. For the first time, the silent rejections become visible. Bugs #2, #3, and #4 are identified by name.
🚀
APR 10–11 OVERNIGHT · FIXES #2, #3, #4
🛠️ 7 Google Ads Mutations Deployed
1 customer-level pointer + 6 call asset pointers repointed from the $1 ghost (179) to the $150 primary action (7476805672). Date buffer patch deployed to nexus_offline_conversions.py — all upload timestamps now UTC-aware with explicit offset. EC4L pivot decision: abandon EC4L for BSP, move to uploadCallConversions (phone calls) + GCLID capture (web forms). Post-fix verification run: 2 uploaded, 0 failed, zero date errors.
Status today: All 7 mutations verified via API. Date bug proven gone. EC4L is officially out of the loop. Bugs #2, #3, and #4 are dead.
🎯
APR 13 MONDAY · 2 PM CT CHECK-IN
📞 uploadCallConversions Goes Live
Monday morning: new uploadCallConversions pipeline built, dry-run produces confidence histogram, single-test upload runs clean. At 2 PM Central, Robert presents five metrics (confidence distribution, test result, diagnostic summary, edge cases, go/no-go). If green, the nightly timer flips on and real sewer call values start flowing to Smart Bidding every night.
🧠 WHY THIS MATTERS FOR THE TEAM
The Apr 2 fix was necessary but not sufficient. It was not wrong, it was not wasted, and it is still holding. But attribution in Google Ads is layered — the front door, the pipe, the clock on the pipe, and the routing system all have to be right, or Smart Bidding gets lied to. Each layer has its own failure mode, and most of them fail silently. That is why we now query offline_conversion_upload_conversion_action_summary as part of the weekly protocol — so if any new bug sneaks in, we catch it in hours, not 21 days.
⚠️

The Three Hidden Bugs

ROOT CAUSE

Last night, while everyone slept, we ran a forensic audit on the Google Ads account. We found three separate bugs, all of them hiding from normal reports. Each one alone would have been a problem. All three together had been quietly burning money for 21 days. Here they are in order from worst to weirdest. → See GCLID Implementation Cheatsheet

🐛 Bug #1
The $1 Call Trap
Every call that came from a Google ad was being filed under a secondary bucket worth $1, not the primary bucket worth $150. Smart Bidding never saw the real value. It thought we were a $1 per call business and bid like it.
✅ Fix
Customer default now points at $150 primary
One API call, 5 seconds. Plus we patched six individual phone number assets that were pointing at a ghost ID. Every call that lands on Bright Side's line through a Google forwarding number now counts at real value.
🐛 Bug #2
The Clock Was Lying
The server that runs our revenue reports was stamping every ServiceTitan job completion with a timestamp that was accidentally 5 hours in the future from Google's perspective. Google rejected 30 to 40 percent of uploads silently just for that reason. The report said "uploaded successfully" anyway.
✅ Fix
Timestamps now use UTC with a 65 minute safety buffer
Parse completion time as UTC, subtract 65 minutes, submit. No more "future timestamp" rejections. Verified working on the first run after deploy: 2 uploaded, 0 failed, zero date errors in the log.
🐛 Bug #3
The Web Form Was Firing Blanks
The pipeline was uploading customer email and phone hashes to Google hoping for a match. The problem is Google needs the website itself to tell Google "this customer submitted a form" at the moment of submission. We never wired that. So the pipeline was shouting names into a crowd where Google had no record of them.
🎯 Plan
Pivot to call matching via Google's own log
Forget the email/phone hash approach for phone leads. Google already has an internal list of every call routed through their forwarding numbers. We reverse match that list against our 3CX phone log, find the Bright Side job that resulted, and upload with the real dollar value. Monday is when we build it.
🔬
The Diagnostic Evidence
Google's API sent us 5 different error codes as the receipts

This is the section that made the debug bulletproof. Instead of guessing what was wrong, Robert sent 5 engineered test events to Google's API, each one designed to trigger a specific failure mode. Google responded with 5 different error codes. Those codes are like a doctor's blood panel. Each number points to one specific organ. Put them together and you have a full diagnosis, not a guess.

🧪 How the 5-Test Diagnostic Works — in 4 steps
📦
STEP 1 · Craft
Build 5 test events. Each one deliberately breaks one specific rule so only one kind of failure can trigger.
📨
STEP 2 · Send
Fire all 5 at Google's API at once with "partial failure mode" on. That means Google returns a separate result for each one.
📮
STEP 3 · Receive
Google sends back 5 separate replies. Each reply has a different error code depending on which rule that test broke.
🎯
STEP 4 · Diagnose
Match each error code to the test it came from. The pattern instantly reveals which bug is real and which was ruled out.
Total time from launching the test to having a full diagnosis: under 5 minutes.
⛔ The First Wall — the Date Gate hid everything else
PRE-FIX
LATER_THAN_MAXIMUM_DATE
"Date is after allowed maximum. Trigger: 2026-04-11 03:40:29-05:00"
Before we could see any of the 5 real errors, all 5 tests first bounced off this one wall. Google's API checks the timestamp BEFORE checking anything else. If the time label says "this happened after right now," Google rejects the whole event and never looks at what's inside. That meant the clock bug was hiding all the other bugs.
🔧
What we fixed
65-minute timestamp buffer
⚙️
What it unlocked
The 5 real errors became visible
💡
The lesson
Fix the obvious gate first
📋 The 5 Tests — each one engineered to fail a specific way
🧪 TEST 1 OF 5 · THE REAL BUG
🧬
HAPPY PATH TEST
What we sent Everything correct — hashed email, hashed phone, consent GRANTED, real format
Error code INVALID_CUSTOMER_FOR_CLICK
📜 Google's exact words
"The click from the imported event is associated with a different Google Ads account. Make sure you're importing to the correct account."
🧒 Plain English
Google found a person matching the email and phone we sent, but that person clicked on someone ELSE's ad, not ours. It's like sending a loyalty reward to a customer who shops at a different store.
🎯 What it tells us
Google's user graph is global. For a local plumber, the odds that a hashed email matches someone who specifically clicked OUR ads is near zero. Email and phone matching alone is broken for home services.
🧪 TEST 2 OF 5 · CONTROL TEST
📝
DELIBERATELY WRONG
What we sent A raw unhashed email (we broke the format on purpose to prove the API checks it)
Error code INVALID_USER_IDENTIFIER
📜 Google's exact words
"Make sure you hash user provided data using SHA-256 and ensure you are normalizing according to the guidelines."
🧒 Plain English
We sent raw data on purpose, like writing your social security number on a postcard. Google said "you have to encrypt this first." We already knew that, but we needed Google to confirm it does check.
🎯 What it tells us
Test 1 used HASHED data and did NOT get this error. That proves our production hashing is correct. Wrong hashing is RULED OUT as a possible cause of the real bug. One suspect eliminated.
🧪 TEST 3 OF 5 · FAKE RECENT TOKEN
🎟️
FORMAT CHECK
What we sent A fake GCLID we made up that LOOKS recent (like we just created the click 10 minutes ago)
Error code UNPARSEABLE_GCLID
📜 Google's exact words
"The imported gclid could not be decoded. Make sure you use the correct gclid format."
🧒 Plain English
A GCLID is like a lottery ticket with a secret watermark. You can't just write a number on paper and hand it in. Google looks at the watermark first. Our fake ticket had no real watermark, so Google said "this isn't even a real ticket."
🎯 What it tells us
GCLIDs must come from real ad clicks. You can't invent them. That means the ONLY way we get real GCLIDs is by capturing them when a real customer clicks a real ad, or from Google's internal call log.
🧪 TEST 4 OF 5 · FAKE LEGACY TOKEN
📜
TIME CHECK
What we sent A fake GCLID formatted to look OLD (like it came from 2023, past the 63-day lookback window)
Error code UNPARSEABLE_GCLID
📜 Google's exact words
"The imported gclid could not be decoded."
🧒 Plain English
Same rejection as Test 3. We hoped Google would say "this ticket is expired" (meaning too old) but instead it said "this ticket is fake" (meaning bad format). Format check happens before age check.
🎯 What it tells us
The 63-day "lookback window" isn't what's blocking us. The problem isn't that our GCLIDs are too OLD. The problem is we don't HAVE any real GCLIDs. Age is not the issue. Absence is.
🧪 TEST 5 OF 5 · ALTERNATE TARGET
👻
GHOST TARGET
What we sent Same as Test 1, but routed to the mystery "ghost" conversion action ID 179
Error code INVALID_CUSTOMER_FOR_CLICK
📜 Google's exact words
"The click from the imported event is associated with a different Google Ads account."
🧒 Plain English
We expected Google to reject this one with "hey that ghost 179 isn't a real mailbox." Instead Google accepted it like a real mailbox and failed on the user-matching step (same as Test 1). So ID 179 IS some kind of real target, just not one with teeth.
🎯 What it tells us
ID 179 is an internal placeholder Google keeps around, not a real conversion target. Our 4 call assets that pointed to it were effectively pointing at nothing and falling back to the broken customer default. Both layers had to be fixed.
🧩 The Pattern — 5 Tests, 3 Distinct Error Codes
1️⃣
INVALID_CUSTOMER_FOR_CLICK
2️⃣
INVALID_USER_IDENTIFIER
3️⃣
UNPARSEABLE_GCLID
4️⃣
UNPARSEABLE_GCLID
5️⃣
INVALID_CUSTOMER_FOR_CLICK
Tests 1 and 5 returned the same code, pointing at the same root cause: cross-account user matching failure. Tests 3 and 4 returned the same code, proving GCLID format validation happens before age validation. Test 2 returned an isolated code, confirming our hashing pipeline is correct. Three distinct failure modes identified in one run, each cause mapped to a specific test.
✅ What We Learned From the 5 Receipts
🧪
Our hashing is correct
Test 2 proved the API actually validates hash format. Test 1 sent correctly hashed data and passed that gate. The bug is not in our code's hashing logic.
🎟️
GCLIDs must come from real clicks
Tests 3 and 4 proved you can't invent GCLIDs. That means the only way to get them is to capture them at the moment of a real click or to pull them from Google's internal call log.
🧬
Email and phone matching alone is dead
Test 1 proved that even with perfect hashing and correct consent, the global user graph belongs to bigger advertisers. A local plumber almost never wins that match. EC4L alone is not viable for home services.
👻
Ghost 179 is a placeholder
Test 5 proved ID 179 accepts events but does nothing with them. It's an internal fallback that Google keeps around. Any call asset pointing at it was effectively silent. We fixed all 6 assets that had that pointer.
⚖️
The Verdict From Google's Own API
Two independent diagnostic runs. The same 5 error codes every time. No ambiguity, no guessing, no "maybe it's this or that." Google told us in plain protocol exactly which bugs existed and in what order. The date bug had to be fixed first before the attribution errors became visible. The attribution errors then confirmed that email and phone hash matching alone is statistically hopeless for a local plumbing business. That is why Monday's build pivots entirely to matching real calls against Google's own internal call log. We know exactly what works, exactly what doesn't, and exactly why. This is the bulletproof part.

What's Already Fixed and Running

LIVE NOW

These are the eight production changes that are already live on the Bright Side Google Ads account. Every single one is verified. The call conversion value went from $1 to up to $11,000 per real job the moment these went live.

🎯
Customer Pointer Fix
The account default for call conversions was pointing at a secondary $1 action. It now points at the $150 primary action.
💰 Impact: Every call default now at $150
📞
6 Call Assets Repaired
Six phone number assets were pointing at a ghost ID from a legacy system. All six now explicitly point at the $150 primary action. Full asset-by-asset breakdown in the table below.
💰 Impact: Every tracking number now correct · see table ↓
Timestamp Bug Killed
The UTC vs Central Time mix-up was rejecting 30 to 40 percent of job uploads silently. Fixed with a 65 minute safety buffer on every timestamp.
💰 Impact: No more silent rejections
🔍
Silent Drop Detection
We built a diagnostic that catches when Google accepts an upload but then drops it downstream. No more "everything looks fine" when really nothing is landing.
💰 Impact: Problems surface in hours not weeks
🧪
Ghost Detection Method
Google Ads has internal placeholder IDs that look like real conversion actions but aren't. We now have a script that catches them automatically.
💰 Impact: Future audits take minutes not hours
📘
Official Protocol Written
The 6 phase, 7 rule debugging process is now saved permanently. Any future pipeline work follows the same protocol. This bug class will never hide for 21 days again.
💰 Impact: Institutional knowledge preserved
📋

Call Asset Breakdown — All 9 Phone Numbers

ASSET-BY-ASSET

Here is every phone number wired into the Bright Side Google Ads account, what bucket it was filing calls into before last night, and what bucket it files them into now. Six of the nine needed a direct fix. The other three were using the "account default" setting, which we also corrected at the customer level, so they automatically benefit from the same fix without individual edits. Every single number now routes calls to the $150 primary action.

# Phone Number Type Was Filing To Now Filing To Status
1 (913) 963-1029 🏢 MAIN LINE 👻 Ghost 179 💰 Calls from ads ($150) ✅ FIXED
2 (913) 963-1029 🏢 MAIN LINE 🪙 Secondary $1 bucket 💰 Calls from ads ($150) ✅ FIXED
3 (913) 963-1029 🏢 MAIN LINE 🪙 Secondary $1 bucket 💰 Calls from ads ($150) ✅ FIXED
4 (913) 963-1029 🏢 MAIN LINE 👻 Ghost 179 💰 Calls from ads ($150) ✅ FIXED
5 (913) 777-6930 🎯 GOOGLE FORWARDING 👻 Ghost 179 💰 Calls from ads ($150) ✅ FIXED
6 (913) 600-7572 🎯 GOOGLE FORWARDING 👻 Ghost 179 💰 Calls from ads ($150) ✅ FIXED
7 (913) 453-3443 🎯 GOOGLE FORWARDING ⚙️ Account default 💰 Calls from ads ($150) ✅ VIA DEFAULT
8 (913) 963-1029 🏢 MAIN LINE ⚙️ Account default 💰 Calls from ads ($150) ✅ VIA DEFAULT
9 9139631029 🏢 MAIN LINE ⚙️ Account default 💰 Calls from ads ($150) ✅ VIA DEFAULT
🏢 MAIN LINE — (913) 963-1029
The real Bright Side Plumbing phone number. Customers who dial directly, see the number in a search result, or find it on the website land here. 3CX answers it, rings Ashton or Jordan, and routes to the right person. 6 of 9 assets use this number.
🎯 GOOGLE FORWARDING NUMBERS
Google-owned tracking numbers that appear in the ad instead of the main line. When a customer taps "Call" on a Google ad, they dial 777-6930, 600-7572, or 453-3443 first. Google logs the call, then forwards it to the main line. This is how Google knows which calls came from an ad.
👻 GHOST 179 — the legacy sentinel
Conversion action ID 179 was an internal Google placeholder from years ago. It does not exist as a real conversion action anymore. 4 of our call assets were pointing at it meaning their calls fell through to the customer-level default, which was also wrong. Both layers were broken.
🪙 SECONDARY $1 BUCKET
Conversion action ID 881920526 named "Calls from ads (1)" is a legacy duplicate of the real primary action. It was a $1 secondary bucket that Smart Bidding ignores. 2 assets were directly pointing here + the customer default was too. All fixed.
Assets Fixed Directly
6
Assets Fixed Via Default
3
GFN Tracking Numbers
3
Routing To $150 Primary
9 / 9
🎯 Why the Google Forwarding Numbers matter most
The three tracking numbers (777-6930, 600-7572, 453-3443) are the canonical proof of "this call came from a Google ad." Google owns them, Google logs every call to them, and Google can match a call on these numbers to the exact ad the customer tapped. Monday's build uses Google's internal log of these forwarding number calls as the starting point for the attribution pipeline. Every call routed through these three numbers can be bridged back to the real ServiceTitan job and uploaded at real dollar value.
📅

Monday's Build — The Real Revenue Pipe

2 PM CENTRAL

Here is what happens Monday. Robert builds the final piece of the attribution pipeline early in the morning, runs it against the last 7 days of real data as a test, and reports five specific numbers at 2 PM. Those five numbers tell us whether the pipe is clean enough to turn on for real. → See GCLID Implementation Cheatsheet

🗓 Monday Apr 13, 2026 · Hour by Hour
🌅
6 AM
Robert creates the new conversion action via script. Takes 2 minutes. Idempotent.
🧪
9 AM
Dry run against last 7 days. Print a confidence histogram. No real uploads yet.
🎯
10 AM
Single real test upload. One event. Capture Google's exact response for review.
📊
2 PM
Robert reports the five numbers. Go or no go on the full production timer.
🎯 The Five Monday Afternoon Metrics
1️⃣
Match Quality
How many real calls matched cleanly between Google's log and our 3CX log. Target: at least 60 percent confident.
2️⃣
Test Upload
One real call upload. Did Google accept it without any error? Empty error box equals green light.
3️⃣
Settle Status
Check Google's internal summary. Did the upload move from pending to successful? Takes 6 to 24 hours.
4️⃣
Edge Cases
Any weird matches to audit together. Ambiguous calls, masked numbers, time zone quirks.
5️⃣
Go or No Go
Together we decide if the pipe is clean enough to run the automated upload every night.
🧪
What Good and Bad Look Like on Monday
Each metric has a clear green-light and red-flag outcome. No ambiguity, no "well maybe."

Robert will present each of the 5 numbers at 2 PM. Here is exactly what a good result and a bad result look like for each one, so the team knows in advance what to listen for. If Robert says the green-light words, we turn the pipeline on. If Robert says the red-flag words, we pause and debug together before anything ships.

1️⃣
Match Quality — the confidence histogram
✅ GREEN LIGHT
≥60% of call_view events match a 3CX record at confidence 0.7 or higher. Most are in the 0.8 to 1.0 range meaning within 30 seconds or tighter. Robert reports: "out of 50 Google ad calls in the last 7 days, 35 matched cleanly."
🚨 RED FLAG
Under 30% at 0.7 or higher, or most matches sitting at 0.5. That means the time window logic or the area-code matching is off. Robert and Kalen debug together before enabling anything.
2️⃣
Single Test Upload — Google's verbatim response
✅ GREEN LIGHT
Empty partial_failure_error. Google accepted the upload without rejection. The single test conversion moves to "pending" in Google's queue.
"partialFailureError": null
🚨 RED FLAG
Any error code. Likely CALL_NOT_FOUND (Google has no record of the call yet, wait longer) or EXPIRED_CALL (beyond 63 days, filter bug).
"callConversionError": "CALL_NOT_FOUND"
3️⃣
Settle Status — did the upload actually land?
✅ GREEN LIGHT
successful_event_count moves from 0 to 1 within 24 hours. Or if we're checking within 6 hours of the upload, at least pending_event_count = 1 with no alerts. This is the first time the new conversion action has ever recorded a successful event.
🚨 RED FLAG
Alert fires with any error code or the upload never moves out of pending after 24 hours. This means Google received it but couldn't match it to a real ad-sourced call.
4️⃣
Edge Case Audit — the weird matches worth reviewing
✅ GREEN LIGHT
Under 5 edge cases total. A handful of ambiguous matches to review. Every edge case has a clear explanation (same customer called twice, DST boundary, masked area code from Google).
🚨 RED FLAG
More than 10 edge cases or any edge case where we genuinely cannot explain the mismatch. That means our matching logic has a bug we need to find before going live.
5️⃣
The Go / No-Go Vote
✅ GREEN LIGHT
All four metrics above clear. Robert enables the nightly automated upload timer. Pipeline runs once per day at 6 AM Central, processes yesterday's ad-sourced calls, attributes them at real revenue value, and feeds Smart Bidding real signal for the first time in 21+ days.
⏸ PAUSE
Any metric in the red zone, timer stays off. Robert and the team debug the specific failure together. Nothing ships until it's clean. Better to delay than to poison Smart Bidding with bad data.
💰
If All Five Metrics Green — Expected Impact
Based on last week's call volume (13 ad-sourced calls routing through the GFNs) and BSP's real sewer job average ($7,082 mean, $11K on big jobs), a clean pipeline feeds Smart Bidding approximately $92,000 in weekly call revenue signal once fully populated. Compare to the current $13 per week under the broken system. That is the difference between an AI bidding model trained on a $1 per call business and one trained on the real Bright Side Plumbing revenue picture. Within 2 weeks of clean signal, expected CPL reduction: 30 to 50 percent.
🗓️

Monday Meeting — Everything You Need to Know

COMPLETE GUIDE

This section answers every question the team might have about the 2 PM Monday meeting before it happens. Who's in the room. How long it'll take. What Robert will share on screen. What questions to ask him. What to do if the result is green, yellow, or red. How to verify the numbers aren't fudged. Read this before the meeting so you walk in with context, not confusion.

🛌
What happens over the weekend

Short answer: nothing that needs your attention. The fixes we deployed last night are already running. The background scripts are picking up calls and jobs automatically. The weekend is for the team to rest. Robert isn't on call. Nothing new goes live. The automated systems run themselves. Here's exactly what's happening in the background while you're off the clock.

SATURDAY
BSP operates normally. Calls come in, jobs get booked. The bleed monitor runs hourly. The offline upload pipeline runs at 6 AM with the fixed clock. Robert rests.
🙏
SUNDAY
Same as Saturday. No Sunday work per Stephanie's rule. The pipeline runs its scheduled job. Nobody touches anything. Robert rests.
🌅
MONDAY 6 AM
Robert starts work. Runs the new pipeline in dry run mode. Posts one test event. Prepares the 2 PM report.
👥
Who's in the 2 PM meeting

The 2 PM Monday meeting is small and focused. Here is the expected attendee list with each person's role during the review.

👨‍💻
Robert
Presents the 5 metrics. Shows the screens. Answers technical questions.
PRESENTER
📝
Stephanie
Reviews the financial impact numbers. Final approval on enabling automation.
APPROVER
🛠
Kalen
Sanity-checks that the revenue numbers make sense vs real plumbing jobs. Owner veto if anything smells off.
OWNER
📞
Ashton (optional)
Only if we need to verify a specific call record or ST job matches what Robert is showing.
VERIFIER
⏱️ Expected duration: 5 to 15 minutes. If everything is green, 5 minutes. If there's an edge case or yellow result, up to 15. If Robert needs to debug something unexpected, the meeting ends quickly and reconvenes later.
🖥️
What Robert will share on screen

Robert will share his terminal and a browser window. Here is what each screen contains so you know what you're looking at without having to ask.

1️⃣
Confidence histogram
A terminal window printing a table like "37 matches at 1.0, 8 at 0.8, 2 at 0.7..." Each number is a count of calls that matched cleanly. You're looking for ≥60% to hit 0.7 or higher.
2️⃣
The test upload response
A JSON blob from Google. You're looking for the words "partialFailureError": null. That means Google accepted the test cleanly. Any error code is a red flag.
3️⃣
The diagnostic summary query
Another terminal output showing Google's internal view. You're looking for total_event_count: 1 and successful_event_count: 1 (or pending if within 24h). Zero alerts.
4️⃣
Google Ads change history tab
The Google Ads web UI change history showing the 7 Apr 10 call asset fixes classified as "Attribute update" not "Strategy change." Those specific fixes did NOT reset the learning clock. Note that additional Apr 11 evening work — creating the ST Call Completed (API) primary action and demoting two stale primaries (duplicate Website Contact Form plus dead EC4L customer match) — DID classify as strategy changes, which is why Sewer entered LEARNING_COMPOSITION_CHANGE. That's intentional: clean goal set now, temporary re-learning, better CPL in 7-14 days.
Questions the team should ask Robert

These are the exact questions to drop in the meeting to keep Robert accountable and catch anything he might accidentally gloss over. Print this list or pull it up on your phone during the meeting.

❓ "Show me the raw Google API response, not your interpretation."
Keeps Robert honest. Makes sure he's not paraphrasing a failure as a success.
❓ "Which specific ServiceTitan job did the test upload match to?"
Forces Robert to show a real job ID that Kalen can cross-check against his actual plumbing work.
❓ "Did that customer actually call from a Google ad, or from direct dial?"
Confirms the call came through one of the Google Forwarding Numbers, not the main line directly.
❓ "Any edge cases where the match confidence felt wrong?"
Makes Robert surface the weird cases he might be tempted to gloss over.
❓ "What's the first sign next week that something is wrong?"
Makes Robert articulate the monitoring plan and what "bad" looks like before we hit it.
❓ "If we say go now, what does the automated job do differently starting tomorrow?"
Makes Robert commit to the exact scope of what we're authorizing.
🚦
Three possible outcomes and what happens next

Robert's 2 PM review will land in one of three buckets. Here's exactly what happens after the meeting in each case so nobody is confused about next steps.

GREEN — everything clean
What it means: All 5 metrics passed. Test upload accepted. Diagnostic summary clean.

What happens next: Stephanie/Kalen say "Go." Robert enables the nightly timer within 10 minutes. Pipeline starts uploading real call conversions at 6 AM Tuesday. CPL should drop visibly within 7-14 days.

Expected frequency: Most likely outcome (80%) given the prep work already done.
YELLOW — partial success
What it means: Test upload accepted but match confidence is lower than expected (say 40% instead of 60%), or the settle-time check isn't complete yet.

What happens next: We do NOT enable the timer. Robert tightens the match logic (duration tolerance, time window), re-runs the dry run, and reconvenes Tuesday or Wednesday with better numbers.

Expected frequency: Possible but unlikely (15%). The prep work should have caught most of this.
🚨
RED — something broken
What it means: Test upload returned an error code (CALL_NOT_FOUND, EXPIRED_CALL, etc). Something fundamental is wrong.

What happens next: Meeting ends quickly. Robert debugs the specific error code using the 5-variation diagnostic method. New meeting scheduled when root cause is identified.

Expected frequency: Low (5%). Most red flags would have shown up in the dry run.
🔒
How the team can verify the numbers are real (trust but verify)

Robert is honest. But the Pact is structured so that even if he weren't, the system would catch it. Here are the three independent audit paths. Each one can be opened by anyone on the team without Robert's help.

📊
Audit path 1 — Google Ads UI directly
Anyone with Google Ads access logs into ads.google.com, goes to Tools → Conversions → ST Call Completed (API), and sees the count with their own eyes. No screenshot, no interpretation, the raw number is right there in the Google UI.
🧾
Audit path 2 — Google Ads change history
Google Ads keeps a permanent log of every config change made to the account with a timestamp and user. The 7 call asset fixes from Apr 10 are in there and each shows as an "Attribute update." The Apr 11 evening conversion action mutations (new ST Call Completed (API) action plus 2 primary demotions) are also in there and DO show as strategy-level changes — which is why Sewer entered LEARNING_COMPOSITION_CHANGE. The change log is the ground truth.
🔎
Audit path 3 — ServiceTitan invoice cross-check
If Robert says "this test upload matched ServiceTitan job #12345 worth $7,082," Kalen can open ST, pull job 12345, and confirm the invoice is real and the value is right. Real job, real dollars, no interpretation needed.
The Pact isn't about trusting Robert. It's about building a system where trust isn't required because the numbers can be independently verified by anyone in the room with 2 minutes of clicking.
👥

What Each Person Needs to Know

BY ROLE

This is the most important section for the team. You do not need to understand any of the technical details above. You only need to know three things: what changed, what it means for your day, and whether there is anything you need to do differently. Here is your card. Each card includes "what to look for as proof it's working" and "what to escalate to Robert immediately" sections so you know how to stay engaged without needing to check in with him every day.

📝
STEPHANIE
Financial visibility, ops approval
🔄 What Changed
Google Ads now values our phone calls correctly. Over the next 2 weeks you should see the weekly call conversion number in Google Ads jump from near zero to several thousand dollars. That is not fake. It is catch up from the old $1 valuation.
📌 What You Need To Do
Nothing technical. Robert handles the work. When you see the jump in call conversion value, do not be alarmed that something shady is happening. We expected this.
✅ Proof It's Working
The "Calls from ads" conversion column in Google Ads grows from $13/week to $9K+/week within 14 days. The weekly CPL number starts dropping. Smart Bidding's optimization score climbs from 0.675 to 0.85+.
🚨 Escalate To Robert
If the call conversion number stays flat at $13/week after 7 days. If Google Ads shows any red alert in the notifications area. If the change history shows any mutations you don't recognize.
🛠️
KALEN
Owner, strategy, expertise
🔄 What Changed
Your fishing analogy was right all along. We found the specific reason Google was under counting our real fish and we fixed it. Expect to see lower cost per lead within 2 weeks as Smart Bidding re-learns on real data.
📌 What You Need To Do
Nothing until Monday 2 PM. The Apr 26 "hands off" rule still holds. The Apr 10 call asset fixes were attribute updates that preserved the learning clock. The Apr 11 evening conversion action cleanup (created ST Call Completed API as new primary, demoted duplicate Website Contact Form and dead EC4L customer match list) DID trigger a goal composition change on the Sewer campaign, which is now in LEARNING_COMPOSITION_CHANGE for 7-14 days. That is expected and intentional — Smart Bidding now optimizes against a cleaner 6-action goal set instead of 10 polluted ones. During the window, do NOT touch Sewer bids, budgets, or targeting. After re-learning, CPL should drop.
✅ Proof It's Working
Sewer campaign CPL drops from current levels to 30-50% lower over next 14 days. Brand campaign CPL tightens similarly. The auction-winning searches start including more "sewer emergency" and "burst pipe" intent vs generic "plumber near me."
🚨 Escalate To Robert
If CPL stays flat or climbs after 14 days. If the sewer campaign starts serving odd locations or search terms. If any campaign's budget gets auto-lowered by Google before Apr 26.
📞
ASHTON
Ops success, front line
🔄 What Changed
Nothing on your day-to-day workflow. The fixes were all behind the scenes in Google Ads. Your call handling, booking process, and ServiceTitan workflow stay exactly the same.
📌 What You Need To Do
Keep closing jobs same day in ServiceTitan when possible. That is the one thing that makes our revenue reporting accurate. Robert is handling everything else.
✅ Proof It's Working
Over the next 2-3 weeks you may notice MORE calls coming in during weather events (the weather engine working harder). The callers may have slightly higher intent on average (fewer tire kickers, more "my basement is flooding"). Call volume increases smoothly without spikes.
🚨 Escalate To Robert
If you notice a sudden spike of spam calls or robocalls (more than 3 from the same number in a day). If callers start asking about the wrong services ("do you do HVAC?"). If any of the Google Forwarding Numbers stop working.
☎️
JORDAN
Phone team, heavy lifter (41 calls today)
🔄 What Changed
Nothing in how you answer calls. You handle the highest call volume on the team and that's not slowing down. The fixes happened in how Google Ads VALUES each call, not in how calls reach you.
📌 What You Need To Do
Keep answering calls like you are. If possible, capture the service type accurately (sewer vs drain vs water heater vs emergency) in ST when you book. That helps Smart Bidding learn which ad types produce which real job types.
✅ Proof It's Working
Call volume stays healthy or climbs slightly. You may notice fewer obvious "shopping around" callers and more "I need someone right now" callers over the next 2-3 weeks as Smart Bidding learns to target higher-intent searches.
🚨 Escalate To Robert
If you start getting repeated calls from the same number within an hour (potential routing loop). If the Google Forwarding Numbers show up as the caller ID instead of the real customer's number (that would break attribution).
👨‍💻
ROBERT
Builder, fixer, deployer
🔄 What Changed
Everything. 7 Google Ads changes deployed, 1 pipeline patch, 16 artifacts produced, a new methodology codified. Monday morning the call upload pipeline goes live in dry run mode, then a single test upload, then report at 2 PM.
📌 What You Need To Do
The full Monday build is scaffolded in the brief. Execute, measure, report. Do not enable the automated timer until the 2 PM review passes.
✅ Proof It's Working
Monday 2 PM 5-metric review passes clean. Tuesday morning first automated run uploads successfully. Wednesday morning diagnostic summary shows successful_event_count climbing. Daily delta between local count and platform count stays under 5 percent.
🚨 Pause Yourself
If ANY Monday metric is red, do not proceed to the automated timer. If the Tuesday morning first run returns any error code that didn't show up in testing, pause and debug. If drift appears between local count and platform count, investigate same day.
📡
Communication cadence between now and Monday standup
Saturday-Sunday: no new messages needed. The weekend is protected time per Stephanie's rule. If something catches fire on Saturday, Robert will Slack the team directly. Otherwise silence means everything is running normally.

Monday 6 AM: Robert starts work. Silent execution. No updates until 2 PM.

Monday 2 PM: 5-15 minute check-in meeting with Stephanie and Kalen. Robert presents. Team votes go/no-go.

Monday evening: Robert shares a 1-paragraph recap in Slack. Screenshots if anything visual. No long message unless something went sideways.

Tuesday-Friday: if green light was given Monday, the pipeline runs automatically every morning. Robert checks diagnostic summary each morning and only escalates if there's drift. Otherwise weekly standup is the next formal touchpoint.
🧒

What All This Actually Means — In Plain English

NO JARGON ZONE

Robert said "7 Google Ads changes deployed, 1 pipeline patch, 16 artifacts produced, a new methodology codified, dry run mode, single test upload, automated timer." That sentence contains 7 things that sound technical but every one of them has a dead simple real world equivalent. Here is each one translated into an everyday picture you already understand.

🪧
Technical thing #1
"7 Google Ads changes deployed"
🎯 Think of it like...
Seven wrong street signs outside the shop. Imagine Google is a delivery driver trying to drop off boxes of "revenue credit" at Bright Side. For weeks, 7 street signs were pointing the driver to the wrong address (a $1 mailbox instead of the $150 mailbox). Last night we repainted all 7 signs. Same driver, same shop, but now the boxes land at the right address.
💡 Real example: the tracking number (913) 777-6930 used to route its calls to a bucket labeled "$1 test." Now it routes to the bucket labeled "$150 real lead." Same phone number, different mailbox.
Technical thing #2
"1 pipeline patch"
🎯 Think of it like...
A punch clock that was running 5 hours fast. Every time a plumber clocked out at 5 PM, the broken clock stamped it as 10 PM. Then when the accountant tried to file the timesheet, corporate rejected it because "no one works past 9 PM." All the work was real, but the broken clock made it look impossible. We fixed the clock.
💡 Real example: ServiceTitan job completions were being stamped as 5 hours in the future. Google rejected them as impossible. Now timestamps are correct AND we subtract an extra hour as safety padding. No more rejections.
🧰
Technical thing #3
"16 artifacts produced"
🎯 Think of it like...
A plumber's custom tool kit for this exact repair. When Kalen runs into a weird pipe configuration, he sometimes makes his own jig or template so next time he sees it, the fix is 10 minutes not 2 hours. Last night Robert built 16 little custom tools (scripts, checklists, documents) so any future version of this same bug hunt takes minutes not days.
💡 Real example: the "ghost detection" script can now scan all Google Ads conversion actions and flag legacy IDs in 5 seconds. Before, it took Robert 2 hours to find them manually.
📖
Technical thing #4
"New methodology codified"
🎯 Think of it like...
A step by step playbook for a specific job type. When a new tech joins Bright Side, Kalen doesn't let them guess how to replace a sewer line. There's a playbook. "First do this. Then check that. If it looks weird, call me before digging." Robert now has the same kind of playbook for any future Google Ads mystery. Six phases, seven hard rules. This exact bug will never hide for 21 days again.
💡 Real example: Rule #1 of the new playbook is "Never trust a local 'uploaded' count alone. Always cross check against Google's own diagnostic." That one rule would have caught this problem on day 2 instead of day 21.
🔎 Deeper Dive — each technical thing in detail
🪧
Deep Dive · Technical Thing #1
The 7 Google Ads Changes
How seven wrong street signs were burning money for 21 days

A "Google Ads change" is exactly what it sounds like: we went into the Google Ads account and changed a setting. There were 7 of them last night, and every single one was the same type of fix: a pointer that was aimed at the wrong place. Think of it like this. Google Ads is a delivery driver carrying boxes of "revenue credit" to Bright Side every time a customer calls from an ad. Each phone number in our account has a little sign next to it that tells the driver which mailbox to drop the box in. We have 9 phone numbers in the account. For 7 of those 9, the sign was pointing at the wrong mailbox. Here is what we fixed, one change at a time.

🚚 The Delivery Driver Diagram
📞
Customer calls
(913) 777-6930 from ad
🚚
Google's truck
carrying $150 credit
📪
❌ $1 mailbox
(was here)
📫
✅ $150 mailbox
(goes here now)
The mistake: the delivery truck's GPS was set to the wrong mailbox for 7 of the 9 phone numbers. The fix: updated the GPS for all 7. The truck still carries the same boxes. They just land in the right mailbox now.
📋 The 7 changes, one at a time
1️⃣
Fixed the account-wide default pointer
The master "where should calls go" setting was pointing at the $1 mailbox. Changed it to the $150 mailbox. This one change alone covers any phone number that doesn't have its own explicit setting.
2️⃣
Repaired main line phone asset #1 — (913) 963-1029
Was pointing at "ghost 179" (a placeholder that doesn't exist anymore). Now points at the $150 mailbox.
3️⃣
Repaired main line phone asset #2 — (913) 963-1029
Was pointing directly at the $1 secondary mailbox. Now points at the $150 mailbox.
4️⃣
Repaired main line phone asset #3 — (913) 963-1029
Also pointing at the $1 secondary mailbox. Now fixed.
5️⃣
Repaired main line phone asset #4 — (913) 963-1029
Pointing at ghost 179. Now fixed.
6️⃣
Repaired Google Forwarding Number — (913) 777-6930 🎯
This is one of Google's own tracking numbers. Pointing at ghost 179. Now fixed. This is the most important one — Google owns this number, logs every call to it, and knows which ad generated it.
7️⃣
Repaired Google Forwarding Number — (913) 600-7572 🎯
Second Google tracking number, same fix. Pointing at ghost 179, now at $150 mailbox. All ad-sourced calls routed through here now credit Bright Side correctly.
💰 Dollar impact of these 7 changes
Before last night, every ad-sourced call was crediting Bright Side $1. Now every ad-sourced call credits the real job value (average $150 for a basic call, up to $11,000 for a big sewer job). If Bright Side gets 13 ad calls per week (current verified volume), that is a shift from $13 per week in Smart Bidding signal to roughly $2,000 per week in realistic signal. Over a month, the AI that decides where to bid on ads will have seen $8,000+ of real revenue instead of $52.
Deep Dive · Technical Thing #2
The Pipeline Patch
How a clock running 5 hours fast was silently rejecting real revenue

"The pipeline" is what Robert calls the little program that runs once a day and tells Google "hey, these 33 real jobs got completed yesterday, please credit them to the ads that brought them in." It runs silently in the background every morning at 6 AM. "Patch" just means a fix. So "1 pipeline patch" means "we fixed one thing in that little program." Here is what was wrong.

🕐 The Clock Drift Diagram
👨‍🔧
Plumber completes job
Real time: 5:00 PM Central
Broken clock stamps it
Labels it: 10:00 PM (wrong!)
🚫
Google rejects
"That time hasn't happened yet"
Why 5 hours specifically: the Nexus computer is in a data center that runs on UTC time. Central Time is UTC minus 5 hours. The old code was reading "now" in UTC but labeling it "Central Time" which made every timestamp look 5 hours in the future. The fix: read "now" and label it correctly AND subtract an extra 65 minutes as a safety cushion, because Google also rejects anything that happened "just now" (they need a few minutes of breathing room to process).
❌ BEFORE THE PATCH
datetime.now()
.strftime("...-06:00")
// labels UTC as Central — BUG
A job completed at 10 PM UTC would get stamped as "10 PM Central Time", which is actually 3 AM UTC. That's 5 hours in the future from Google's perspective. Rejected.
✅ AFTER THE PATCH
datetime.now(timezone.utc)
.subtract(minutes=65)
.strftime("...+00:00")
A job completed at 10 PM UTC gets stamped as "8:55 PM UTC" (65 minutes in the past from Google's perspective). Always safely in the past. Accepted.
📊 Verified impact from the post-patch run
BEFORE PATCH
2 date errors
every run
AFTER PATCH
0 date errors
verified on first run
🧰
Deep Dive · Technical Thing #3
The 16 Artifacts
Every custom tool built last night, organized like a plumber's bench

An "artifact" is a file we created and saved for future use. It could be a script that runs a specific task, a memo that captures a lesson learned, a visual reference like this document, or a chunk of institutional knowledge stored in the Nexus brain. Think of it like Kalen's custom tool bench. After a hard diagnostic, he sometimes modifies a standard tool or writes a quick note on the shop wall. Those modifications save hours on the next similar job. Robert built 16 of those last night. Here is what each category looks like.

🔧
8 Executable Scripts
Python tools that run specific tasks
create_st_call_conversion_action.py — creates the new tracking bucket in Google Ads. Idempotent (safe to re-run).
diagnostic_upload_debug.py — the 5-variation test that exposed the hidden bugs.
gaql_p0_diagnostics.py — health-check script for Google Ads.
execute_call_pointer_fixes.py — the one that executed last night's 7 mutations.
audit_conversion_actions_full.py — the ghost detector.
call_volume_full.py — the call analytics with WoW / MoM comparison.
nexus_bleed_monitor.py — runs hourly, catches ad waste and Daniel spam.
nexus_offline_conversions.py (patched) — the main upload pipeline with the date fix.
📄
2 Working Documents
The Monday build briefs
monday_uploadcallconversions_build_brief.md — the full architecture and code scaffolding for Monday's new pipeline.
MONDAY_PIPELINE_DEBRIEF.md — a 60-second session restore document so if anything resets between now and 2 PM Monday, context is recoverable in seconds.
💡 These two documents alone mean Monday's work can be completed even if Robert forgets everything overnight. The recipe is written down.
🧠
5 Permanent RAG Chunks
Institutional memory saved to the Nexus brain
5-variation diagnostic methodology — how to isolate API failure modes.
Reverse-match canonical source methodology — the architecture pattern that won last night.
UTC label mislabel bug pattern — the clock bug, codified as a searchable lesson.
7-step pipeline verification sequence — never skip a phase.
Attribution pipeline debugging protocol — the official process.
💡 Any future Nexus session touching attribution automatically loads these. The next debug won't take 12 hours. It'll take 15 minutes.
📋
1 HTML Document (this one)
The team-facing breakthrough debrief
The document you're reading right now. Lives at morpheus.callbrightside.com/documents/BSP_Ads_Attribution_Breakthrough.html. Cross-linked to Sacred HTML v2 and the Battle Plan so the three docs navigate as a set. Anyone who opens this can understand what happened, what's fixed, and what's coming Monday without asking Robert a single question.
Scripts
8
Briefs
2
Brain chunks
5
HTML docs
1
🎯 Total: 16 custom tools, all reusable, all saved permanently to the Nexus system
🔧 Every Script, One at a Time

Each of the 8 scripts has a specific job. Here is what every one of them actually does in real-world terms, what analogy makes the most sense for that tool, and when Robert would reach for it. Think of this as the labeled drawer inside the tool bench. If a future problem comes up, the right tool is already sitting there waiting.

SCRIPT 1 OF 8
🗂️
create_st_call_conversion_action.py
The filing cabinet installer
🎯 Think of it like... ordering a new filing cabinet and having the supplier install it. "Idempotent" means if you call the supplier a second time, they check first and say "you already have this cabinet, you're good." No duplicates, no confusion. Safe to re-run forever.
When Robert uses it: Monday at 9 AM. One command. Creates the new "ST Call Completed" tracking bucket in Google Ads, captures the new ID, writes it to a file. If he runs it twice by accident, it just returns the existing ID. Zero risk.
SCRIPT 2 OF 8
🧪
diagnostic_upload_debug.py
The 5-sample allergy test
🎯 Think of it like... a doctor's allergy panel. Instead of guessing "is it peanuts or dairy?" they prick your skin with 5 different substances and watch which ones react. This script sends 5 differently-crafted test events to Google's API and catalogs which ones fail and with what specific error code.
When Robert uses it: any time a Google Ads upload is failing silently and we can't tell why. Last night this tool revealed the INVALID_CUSTOMER_FOR_CLICK root cause in one run. Future bugs of this type get diagnosed in minutes, not days.
SCRIPT 3 OF 8
🩺
gaql_p0_diagnostics.py
The physical exam
🎯 Think of it like... the quarterly health check at the doctor. Blood pressure, temperature, heart rate, reflexes. A standard panel that runs the top-level numbers and reports whether anything looks off. For Google Ads, it checks conversion tracking settings, call reporting state, optimization score, and auto-tagging status.
When Robert uses it: at the start of any session where something looks weird in Google Ads. Confirms the basics are healthy before we go digging into a specific issue. Catches config regressions fast.
SCRIPT 4 OF 8
🔌
execute_call_pointer_fixes.py
The one-time electrician
🎯 Think of it like... hiring an electrician to come out and rewire exactly 7 outlets that were wired to the wrong breaker. Runs once, fixes the specific thing, then its job is done. This is the script that executed the 7 Google Ads mutations last night. Archived now for proof of what happened.
When Robert uses it: already used, once, last night. Kept on file as the audit trail so anyone can see exactly what was changed, in what order, with verification at every step. Never needs to run again.
SCRIPT 5 OF 8
🔍
audit_conversion_actions_full.py
The metal detector
🎯 Think of it like... a metal detector sweeping a yard. Walks through every conversion action in the Google Ads account, checks 4 signals (origin, type, recent activity, zzz. prefix), and classifies each one as LIVE, DORMANT, or GHOST. Separates the real tools from the rusted-out ones.
When Robert uses it: monthly audit to catch any new ghost IDs creeping in, or before any major conversion action cleanup. Takes 5 seconds what used to take 2 hours of manual inspection.
SCRIPT 6 OF 8
📞
call_volume_full.py
The call log analyst
🎯 Think of it like... reviewing the day's receipts at the end of a shift. Who called when, how long did they stay on the line, which day was busiest, how does this week compare to last week. Pulls from 3CX, prints clean week-over-week and month-over-month comparisons.
When Robert uses it: before every Monday standup and any time a call volume question comes up. Produces the data that goes into Sacred HTML v2's Call Health section. Replaces 30 minutes of manual SQL with one command.
SCRIPT 7 OF 8
💧
nexus_bleed_monitor.py
The leak detector (hourly)
🎯 Think of it like... a leak detector crawling through the walls every hour listening for drips. For BSP's ad spend it checks: any campaign wasting money at $0 conversions? any phone number getting hit by repeat spam callers? It writes its findings to a text file so the small leaks get caught before they become floods.
When Robert uses it: it runs automatically every hour in the background. Robert checks its output once a day. Already caught 2 spam caller patterns and flagged a Sump Pump campaign wasting money this morning.
SCRIPT 8 OF 8
🚚
nexus_offline_conversions.py (patched)
The nightly delivery truck
🎯 Think of it like... the delivery truck that picks up yesterday's completed jobs from ServiceTitan every morning and drops off "credit slips" at Google Ads. This is the core of the existing offline conversion pipeline. Robert patched its broken clock last night (the 65-minute timestamp buffer) so it stops rejecting deliveries for being "too fresh."
When Robert uses it: runs automatically every day. Robert doesn't touch it. But the patch last night was the single biggest fix to the existing code (eliminated ~40% of silent drops from the clock bug).
🧠 Every Brain Chunk, One at a Time

A "RAG chunk" is a lesson written down and saved to the Nexus AI's permanent memory. When any future session mentions related keywords, these chunks automatically load into Claude's working memory before any work starts. Think of it like pinning sticky notes to the shop wall that every new technician has to read before they pick up a tool. Five lessons from last night are now pinned. Here is what each one says and when it will fire.

🧬 BRAIN CHUNK 1 OF 5
🧬
The 5-Variation Diagnostic Methodology
How to isolate the cause of a silent API failure — in 15 minutes, not 21 days

This chunk captures the technique that broke the entire 21-day mystery last night. When an API is accepting your requests (200 OK) but nothing shows up downstream, you cannot debug by staring at the code. You have to run a controlled experiment where each test event is DESIGNED to fail a specific way. Whichever test gives you an actual error code tells you exactly where the bug is hiding.

📋 The 5-variation test matrix
Test 1 — Happy path: everything right (correct format, valid data, full consent). If this fails, the rest of the tests tell you WHY.
Test 2 — Deliberate format bug: send raw data where hashed is required. Proves the API's validation actually works. Establishes a known-failing baseline.
Test 3 — Fake recent identifier: tests whether the API validates format before checking if the ID exists. Separates "unparseable" from "not found."
Test 4 — Fake legacy identifier: tests time-window checks vs format checks. Tells you which gate fires first.
Test 5 — Alternate resource target: sends the happy-path event to a different bucket. Proves whether the bug is about WHAT you're sending or WHERE you're sending it.
🎯 The doctor analogy
A doctor sees a patient with vague symptoms. Could be allergy, could be virus, could be reaction to medication. Instead of guessing, they run a controlled panel. Each test can only trigger for one specific cause. Positive on test 2 and nothing else? It's the allergy. Negative on all? It's psychological. The panel is designed so exactly one test lights up, and that's your answer.
🩺 The mechanic analogy
A mechanic hears a weird engine noise. Could be 6 different things. Instead of pulling the whole engine apart, they hook up a sensor and perform 5 isolated tests: rev up, rev down, cold start, warm idle, full load. Only one test triggers the noise. Now they know exactly which subsystem to open up.
📖 What it caught last night (in one run)
Test 1 → INVALID_CUSTOMER_FOR_CLICK (the real bug). Test 2 → INVALID_USER_IDENTIFIER (confirmed our hashing was correct). Test 3 → UNPARSEABLE_GCLID (confirmed format-before-lookup order). Test 4 → same as Test 3 (confirmed time window doesn't fire first). Test 5 → same as Test 1 (confirmed the ghost action ID wasn't the cause). In ONE run, 5 separate hypotheses tested, 5 distinct error codes returned, root cause identified. Total time from test launch to diagnosis: under 5 minutes.
🚫 What happens if this chunk is missing
You debug by trial and error. You add a print statement, re-run, wait, read logs, form a new hypothesis, change the code, re-run. 10 minutes per attempt, 20 attempts, nothing isolated, 4 hours later you still don't know if it's the timestamp, the hash format, or the API key. That's how we burned a full evening before this chunk existed.
🔔 How this chunk fires in future sessions
Keywords that trigger auto-load: "silent drop" · "partial failure" · "200 OK but nothing works" · "API upload debugging" · "why are my conversions not showing" · "why is this not matching" · "Google Ads not counting" · "Meta conversion API debug" · "LSA attribution missing"
When any of these appear in a future session, Claude immediately loads this chunk BEFORE writing any debug code. First action becomes "construct 5 isolated test events" instead of "read the pipeline code." 15 minutes instead of 4 hours.
🔄 BRAIN CHUNK 2 OF 5
🔄
Reverse-Match via Canonical Source Methodology
The architecture pattern that unlocked Monday's build — ask the shipper, not the driver

When you need to connect events across two systems, the wrong move is to start from the system you're sitting in. The right move is to start from the system that OWNS the truth about the linking event. This chunk captures that reversal. It sounds abstract. It is dead concrete and it saves weeks of wrong-direction engineering on every future integration.

❌ THE WRONG WAY (source-led)
Start from the business outcome
"I have a completed ServiceTitan job. Let me search for the ad click that caused it." You're asking the delivery driver where a package came from. He knows where he dropped it off, but he doesn't reliably know the origin.
Requires a manual tag (lead_source), a human to fill it in, and a guessing process when the tag is wrong or missing.
✅ THE RIGHT WAY (canonical-led)
Start from Google's canonical list
"Google, give me the list of every call that came from an ad. Now let me find the ServiceTitan job that matched each one." You're asking the shipper for the canonical manifest. They own the truth. No guessing, no missing tags.
Uses Google's own call_view, no human tagging dependency, deterministic match by timestamp and area code.
📦 Package tracking
Missing package? Don't ask the driver. Ask Amazon. They have the label scan, the route data, the proof of origin.
🏗️ Blueprint lookup
Need to know what's behind a wall? Don't guess. Go to the city's building records office. They have the canonical blueprint.
🧬 DNA testing
Who's the father? Don't ask the kid's birth certificate. Ask the DNA. Only one source is canonical for paternity.
📖 What it caught last night
We almost built the entire Monday pipeline source-led: pull ServiceTitan jobs filtered by lead_source='Google', then hunt for matching 3CX calls. Problem: Ashton manually tags lead_source. The tag is often wrong, missing, or generic. We would have attributed maybe 30 percent of real ad calls. Flipping to canonical-led (start from Google's call_view, reverse-match to 3CX) gives us near 100 percent attribution with zero human tagging dependency. Same pipeline code, different architectural direction, dramatically better result.
🚫 What happens if this chunk is missing
You build every integration source-led because that's the natural direction when you're sitting in the downstream system. You end up adding complexity to work around missing or incorrect tags. Your match rate stays low. You blame the tagging process. You don't realize you were building the wrong direction the whole time.
🔔 How this chunk fires in future sessions
Keywords that trigger auto-load: "attribution pipeline" · "CRM to marketing join" · "conversion matching" · "how do I connect X system to Y system" · "offline conversion upload" · "call tracking" · "source attribution" · "lead source accuracy"
Before proposing any architecture, Claude asks: "which system owns the canonical record of the linking identifier?" If that system has an API, start there. If the answer is "our CRM with manual tags," Claude will push back and look for a canonical source first.
⏰ BRAIN CHUNK 3 OF 5
The UTC-Label-Mislabel Bug Pattern
The clock bug that burned 21 days — codified as a searchable lesson so it can never happen silently again

This chunk is specifically about one class of bug that is invisible in local testing, passes all unit tests, never throws an exception, and corrupts roughly 40 percent of your data for as long as the code runs. It is the single most common attribution pipeline bug at small shops. Here is the exact pattern so any future session can spot it in under a minute.

🧬 Anatomy of the bug
Step 1: The server runs on UTC (almost all cloud servers do).
Step 2: The code calls datetime.now() which returns a timezone-naive object containing the UTC value.
Step 3: The developer formats the output as "%Y-%m-%d %H:%M:%S-05:00" because they want "Central Time."
Step 4 (the bug): The datetime VALUE is UTC but the LABEL says -05:00. The downstream parser reads this as "5 hours ahead of the labeled time zone" = 5 hours in the future from actual UTC.
Step 5: Google rejects every event with LATER_THAN_MAXIMUM_DATE silently if partial_failure is enabled. The local "uploaded successfully" count keeps climbing. The actual attribution never lands.
🚩 Red flags that mean you have this bug
• Error message contains "after allowed maximum" or "in the future"
• Local "uploaded" count keeps climbing but platform reports 0 successful
• Subtracting 5 hours from your timestamps makes errors disappear
• Your VM is in a datacenter (UTC) but your business is in Central
✅ The safe fix pattern
# Never this:
datetime.now().strftime("%Y...-05:00")
# Always this:
datetime.now(timezone.utc)
.strftime("%Y...+00:00")
# Plus 65-min safety buffer
🎯 The shop wall sign analogy
Imagine there's a punch clock in the break room. Every new hire makes the same mistake on week 1: they read the clock, think it's local time, and sign their timesheet with whatever the clock showed. But the clock is set to UTC for some old payroll system reason. So they write "5 PM" on the timesheet when the real Central time is noon. Payroll keeps rejecting timesheets that claim hours worked before the shift started. After the fifth new hire makes the same mistake, someone finally tapes a big yellow sign above the clock: "THIS CLOCK IS UTC. SUBTRACT 5 HOURS FOR CENTRAL." Problem solved forever for every future hire. This RAG chunk IS the yellow sign, pinned to every future Nexus session automatically.
📖 What it caught last night
The production pipeline had this EXACT bug for at least 21 days. Every night, it silently rejected 30 to 40 percent of all offline conversion uploads. We found it only because we ran the 5-variation diagnostic and saw LATER_THAN_MAXIMUM_DATE on all 5 test events. Robert patched it with the UTC-aware + 65-minute buffer pattern, verified zero date errors on the next run. The patch itself was 12 lines of code. The insight was everything.
🔔 How this chunk fires in future sessions
Keywords that trigger auto-load: "timestamp" · "datetime" · "timezone" · "LATER_THAN_MAXIMUM_DATE" · "future date error" · "UTC" · "Central Time" · "datetime.now" · "tz-aware" · "strftime" · "clock drift"
Before writing any timestamp logic, Claude loads this chunk and asks two questions: (1) What timezone is the server in? (2) What timezone is the destination API expecting? Mismatched answers trigger a mandatory timezone-aware rewrite before any code ships.
✈️ BRAIN CHUNK 4 OF 5
✈️
The 7-Step Pipeline Verification Sequence
Never skip a step. Ever. Pilots don't skip pre-flight checklists even on their 10,000th flight.

A verification sequence is a list of checkpoints that must happen in order, and each one blocks the next. This chunk captures the 7-step sequence for validating any new data pipeline before it goes to production. Skipping ANY step exposes you to exactly the kind of silent failure we just fixed. The sequence is non-negotiable.

🧾 The 7 steps in strict order
1️⃣
Query the platform's own diagnostic BEFORE building anything. Google Ads has offline_conversion_upload_client_summary. Meta has Events Manager. LSA has the lead pane. Start there.
2️⃣
Run the 5-variation diagnostic upload. Confirms what errors the API even CAN return, so you know what good vs bad looks like.
3️⃣
Write the architecture doc (not code). Timezones, match tolerances, orphan handling, canonical source direction. Plain English. No code yet.
4️⃣
Build the pipeline with a --dry-run flag. Prints what it WOULD post, posts nothing. Eyeball the output against real data.
5️⃣
Post ONE real event. Not 2. Not 10. Not "a small batch." Exactly one. Capture the raw API response verbatim.
6️⃣
Wait 24 hours. Re-query the diagnostic. Did the platform's successful_event_count actually increment? If no, do not proceed.
7️⃣
Only now enable the scheduled timer. Every previous step was a gate. The automated job is the reward for passing all six.
✈️ The pilot's pre-flight checklist analogy
Commercial pilots run the same pre-flight checklist every single flight. 10,000 flights, 10,000 checklists, every item in order, no skipping. A captain with 30 years of experience still goes through "flight controls free and correct, trim set, flaps set, transponder on, altimeter set" every single time. Not because they forget, but because skipping one item once is how planes crash. This chunk applies the same principle to pipelines. Each of the 7 steps is a checkbox that blocks the next. You never skip one. Not even when you're in a hurry. Not even when the code "looks fine." Not even when you've done it 100 times. The checklist runs every time.
📖 What it would have caught (and what we learned)
If this sequence had existed on day 1 of the original pipeline, we would have been blocked at step 6: post one real event, wait 24 hours, query the diagnostic. The diagnostic would have said "0 successful events, 100% INVALID_CUSTOMER_FOR_CLICK alert." The pipeline would never have gone to production in the first place. We would have caught the EC4L cross-account issue on day 2, not day 21. That's 19 days of burned Smart Bidding signal we could have saved with this one list of steps.
🚫 What happens if this chunk is missing
You skip steps because they feel like bureaucracy. You go from "code works in my test" straight to "enable the scheduled job." You get a silent failure that hides for weeks or months. You lose signal, waste ad spend, and erode trust in the data. The 7 steps feel slow. They are fast compared to any other alternative.
🔔 How this chunk fires in future sessions
Keywords that trigger auto-load: "enable the timer" · "let's just turn on" · "schedule the job" · "deploy to production" · "launch the pipeline" · "go live" · "ready to run automatically"
Any phrase that sounds like "skip to production" triggers this chunk and forces a review of all 6 prior steps. Claude will explicitly ask "did you post one test event? did you wait 24 hours? what did the diagnostic say?" If any answer is no, the timer stays off.
📜 BRAIN CHUNK 5 OF 5 · THE MASTER PROTOCOL
📜
Attribution Pipeline Debugging Protocol (Official Process)
The binding master document that wraps chunks 1-4 into one official BSP procedure

The first four brain chunks are individual lessons. This fifth chunk is something different. It is the official binding procedure that says "at BSP, when you work on any attribution pipeline, you follow this exact process. Not optional. Not suggestions. Not pick-your-favorite-parts." It exists because individual lessons can be ignored, but an official procedure has authority.

⚖️ Why this chunk has more weight than chunks 1-4
Chunks 1-4 are like individual safety tips on a shop wall. Useful. Can be read. Can be ignored. This chunk is like the written safety procedure that every new hire signs before they're allowed to touch the equipment. It references the other 4 chunks explicitly, ties them together with enforcement language, and makes the combined process non-optional. Any Nexus session that touches attribution work auto-loads this chunk FIRST, and the diagram displays at the top of the response before any other work begins.
📋 What the master protocol contains
• The 6 phases (Discovery → Monitoring)
• The 7 hard rules (never/always list)
• Pointers to chunks 1-4 for specific techniques
• Reference to the BSP Apr 10 session as the canonical "this is what happens when you skip it" example
• Required deliverables for each phase
• Explicit go/no-go gates between phases
🎯 What it does NOT do
• Does not replace good engineering judgment on edge cases
• Does not cover every possible failure mode (chunks 1-4 handle specifics)
• Does not apply to non-attribution code (it is scoped specifically to pipelines)
• Does not allow skipping phases even when the code "looks fine"
📖 The Ford dealer procedure manual analogy
Every Ford dealership in the country has the same procedure manual for diagnosing electrical issues on a specific model. It isn't a tip sheet. It's the official, warranty-backed, Ford-certified procedure. If a tech diagnoses an electrical issue WITHOUT following that manual and gets it wrong, warranty doesn't cover the rework. The manual has teeth. Brain chunk 5 has the same teeth for BSP attribution work. Any debugging that bypasses the protocol and leads to a silent failure is a trackable, repeatable, preventable mistake. The protocol is how we make sure mistakes don't repeat.
🔗 How the 5 chunks work together
Chunk 5 is the front door. It loads first, displays the 6-phase diagram, and references chunks 1-4 inline. As the session progresses and hits specific moments (diagnostic time, timezone handling, architecture decision, validation gate), the corresponding specialized chunk loads on top of chunk 5 with more detail. Think of chunk 5 as the table of contents and chunks 1-4 as the chapters. You always open the book at the table of contents first.
🔔 How this master chunk fires in future sessions
Keywords that trigger auto-load (broader than the others): "attribution pipeline" · "offline conversion" · "Google Ads API" · "Meta conversion API" · "ServiceTitan integration" · "CRM to ads" · "call tracking" · "GCLID" · "uploadClickConversions" · "uploadCallConversions" · "Enhanced Conversions" · "EC4L" · "LSA leads" · "silent failure" · "pipeline debugging"
When any of these appear, chunk 5 loads first and displays the 6-phase protocol diagram at the TOP of the response. Before any implementation code. Before any diagnosis. The team sees the protocol, knows where we are in the 6 phases, and can hold Robert accountable to not skipping any of them. The goal is that every attribution task at BSP walks through the 6 phases explicitly, with the Monday 2 PM check-in being the living reference implementation.
The compounding payoff
Every script above is a tool Robert can pick up instantly next time. Every brain chunk is a lesson Claude already knows before any session starts. The next time something like this comes up, Robert doesn't start from zero. He starts with a labeled drawer of 8 tools and 5 pinned lessons already in Claude's working memory. Last night's 12-hour debug becomes next time's 15-minute fix. That's not an exaggeration. The methodology mapping last night was the slow part. Execution is fast once the map exists.
📖
Deep Dive · Technical Thing #4
The New Methodology
The playbook that prevents the next 21-day silent failure

"Methodology" is a fancy word for "the way we do things." Every trade has them. Kalen has a step-by-step process for replacing a sewer line. You don't start digging before you run the camera. You don't run the camera before you talk to the homeowner. You don't talk to the homeowner before you pull records. There's a proven order. Last night we discovered that we needed the same kind of step-by-step process for debugging ad attribution, because guessing takes 21 days and the process takes 15 minutes. Here's the playbook we wrote.

📐 The 6 Phases (in order, never skip a phase)
🔍
Phase 1 — Discovery
Ask "who is the canonical source of truth for this data?" before writing any code. Check the platform's own diagnostic endpoints first.
🧪
Phase 2 — Diagnosis
Run the 5-variation diagnostic if the pipeline exists but is broken. Deep-parse the error codes. Categorize into date, format, matching, or permissions.
🏗️
Phase 3 — Architecture
Source-led or canonical-led? Document every timezone explicitly. Define match tolerance windows. Define orphan handling.
🛠️
Phase 4 — Build (dry run)
Scaffold the code. ALWAYS start with a dry run that prints without posting. Eyeball the output on last 7 days of real data. Never go live from first build.
Phase 5 — Validation
Post ONE real event. Wait 24 hours. Query the diagnostic summary. Only enable the automated timer AFTER this phase passes. NEVER skip it.
📡
Phase 6 — Monitoring
Weekly audit for the first 30 days. Local "uploaded" count vs platform's "successful" count. Delta should be near zero. Drift means investigate immediately.
🚨 The 7 Hard Rules (codified from last night's pain)
1️⃣ NEVER trust a local "uploaded" count alone. Always cross check against the platform's own diagnostic.
2️⃣ NEVER skip the dry run phase. The first live event is always a test, never a batch.
3️⃣ NEVER enable an automated timer without passing the 24-hour validation check.
4️⃣ ALWAYS deep-parse partial failure errors. Don't rely on a library's flattened "success/fail" counts.
5️⃣ ALWAYS verify timezone handling explicitly when crossing system boundaries.
6️⃣ ALWAYS ask "which system is canonical for the linking identifier?" before choosing architecture.
7️⃣ NEVER claim "working" from an API 200 OK response alone. Verify end to end.
🔐
Why this is the real win from last night
The 7 Google Ads fixes pay off in the next 2 weeks. The clock patch pays off every day the pipeline runs. The 16 tools pay off when the next similar bug shows up. But the methodology pays off forever. It prevents the entire class of problem. Any future Nexus session that touches any attribution pipeline automatically loads this playbook before the first line of code gets written. The next bug of this type gets caught on day 1 instead of day 21. That means less money burned, faster debugging, and Stephanie/Kalen never having to hear "we just discovered something that's been broken for 3 weeks" again.
📐 Every Phase, One at a Time

Each of the 6 phases is a specific checkpoint with a specific question to answer. You can't shortcut around them. Here is what each phase looks like in plain English, the real-world thing it's modeled on, what it would have caught last night if we'd had it in place on day 1, and when Robert reaches for it.

PHASE 1 OF 6
🔍
Discovery — ask before you write
The "pull the records first" rule
🎯 Think of it like... Kalen pulling the property records before he digs. He doesn't grab the shovel until he's checked the utility map, the homeowner history, and the permits on file. One minute of records work saves eight hours of the wrong dig. For attribution work: identify the canonical source of truth for the linking identifier BEFORE writing any code.
📖 What it would have caught last night: the existence of Google's call_view resource. If Phase 1 had run day 1, we would have discovered call_view in 30 seconds and built the pipeline around it from the start. Instead we spent 12 hours learning the hard way that source-led architecture doesn't work for home services.
⏰ When to do it: the very first hour of any new pipeline project. Before opening a code editor. Before writing a single line of anything.
PHASE 2 OF 6
🧪
Diagnosis — run the test before you guess
The "hook up the OBD scanner" rule
🎯 Think of it like... a mechanic plugging the OBD scanner into the car before popping the hood. The car has built-in diagnostic codes for a reason. You read them first, then you investigate the actual problem the codes point to. Never guess when the system itself is offering to tell you what's wrong.
📖 What it would have caught last night: INVALID_CUSTOMER_FOR_CLICK at 100% error rate. If we had run the 5-variation diagnostic and queried offline_conversion_upload_client_summary on day 1, Google would have told us in protocol "these uploads are being rejected because they don't match clicks on your account." 21 days saved.
⏰ When to do it: any time an existing pipeline is "running" but something feels off. Any time local success counts don't match platform success counts. Before opening the source code to debug manually.
PHASE 3 OF 6
🏗️
Architecture — blueprint before building
The "draw the plumbing run before cutting pipe" rule
🎯 Think of it like... Kalen sketching the full rough-in on a napkin before cutting a single piece of pipe. Which joist does it cross? Where does it transition from copper to PEX? What's the venting plan? Answer every question on paper first. The cutting and soldering is the easy part once the sketch is done. For attribution code: decide source-led or canonical-led, document every timezone, define tolerance windows, define orphan handling — all BEFORE the first code line.
📖 What it would have caught last night: the "canonical-led is better than source-led" decision. The UTC vs Central timezone handling. The "what do we do with orphan calls" rule. All three were things we made up on the fly at 2 AM instead of thinking through on paper at 8 PM.
⏰ When to do it: right after Phase 1 discovery. Before a single line of code. Write the architecture document first. Monday's build brief is exactly this — a pre-code architecture sketch.
PHASE 4 OF 6
🛠️
Build — dry run before live run
The "pressure test with air before water" rule
🎯 Think of it like... Kalen pressure-testing new plumbing with air before turning on the water. If there's a leak with air, nothing gets damaged. If there's a leak with water, the homeowner's ceiling is ruined. Always test in the mode where mistakes are cheap, BEFORE testing in the mode where mistakes are expensive. For code: run the pipeline with a "dry run" flag that prints every match but posts nothing. Eyeball the output. Only then graduate to live posting.
📖 What it would have caught last night: nothing directly, because the existing pipeline was already "live." But it will catch every Monday-morning bug before we post a single wrong conversion. This phase is the safety net for every future build, including tomorrow's.
⏰ When to do it: Monday 9 AM. Before the single test upload. Before the automated timer. This is the "mixing the batter" step from the pancake analogy.
PHASE 5 OF 6 · THE CRITICAL GATE
Validation — one test pancake
The "wait 24 hours and verify" rule · THE GATE
🎯 Think of it like... cooking ONE pancake from a new batter before making 50 for the brunch crowd. The single pancake tells you if the recipe is right. You sacrifice 2 minutes and one pancake's worth of batter to save the entire brunch from being a disaster. For pipelines: post ONE real event. Wait 24 hours for Google's internal settle process. Query the diagnostic summary. Only if successful_event_count actually incremented does the pipeline graduate to production.
📖 What it would have caught last night: the whole 21-day silent failure. If Phase 5 had been in place on day 1 of the original pipeline, we would have posted one test event, waited 24 hours, queried the summary, seen "0 successful", and known the pipeline was broken from the first 24 hours. Not 21 days.
🚫 Never skip rule: this is the hardest phase because it requires patience. 24 hours feels slow when you want to ship. But skipping it is what caused the 21-day bug. NEVER enable an automated timer without completing this phase. Monday's Pact has this as its #1 rule.
PHASE 6 OF 6
📡
Monitoring — weekly audit for 30 days
The "smoke detector check" rule
🎯 Think of it like... the smoke detector in a house. Quiet 99 percent of the time. Loud 1 percent. The 1 percent is when it matters most. For the first 30 days after any new pipeline goes live, we run a weekly audit: compare our local "uploaded" count to the platform's "successful" count. Delta should be near zero. Any drift means silent drops are starting again, and we investigate before they compound.
📖 What it would have caught last night: if Phase 6 had been in place from day 1, week 1's audit would have shown "local count 22, Google count 0." We'd have investigated on day 7, not day 21. 2 weeks of burning signal saved.
⏰ When to do it: every Sunday night during the first 30 days of any new pipeline. Run the comparison script, eyeball the delta, flag drift immediately. After 30 days of clean audits, reduce to monthly.
🚨 Every Hard Rule, One at a Time

The 6 phases tell you WHAT to do in what order. The 7 hard rules tell you what you're NEVER allowed to do, no matter how tempting. Each rule was written in blood last night. Every one of them comes from a specific moment in the debug where we almost missed a bug because we didn't have the rule yet. Here is each rule, the pain that taught us, and the analogy that makes it stick.

RULE 1 OF 7 · NEVER
🚫
Never trust a local "uploaded" count alone
Always cross check against the platform's own diagnostic
🎯 Think of it like... never trusting the delivery driver's log alone. The driver's log says "delivered." You confirm with the recipient. If the recipient says "nothing arrived" then the driver's log is fiction.
🩸 The pain that taught us: 154 "successful" uploads in our local history file over 21 days, 0 successful in Google's diagnostic summary. The local count was meaningless. The platform count was truth.
RULE 2 OF 7 · NEVER
🚫
Never skip the dry run phase
The first live event is always a test, never a batch
🎯 Think of it like... never test-driving a new road in an RV with 4 kids asleep in the back. You scout it in the work truck first. Same principle: test with data that can't hurt anything before you test with data that can.
🩸 The pain that taught us: the original pipeline went from "built" to "batch-uploading 33 events" with no dry run in between. If the first batch had been wrong, all 33 events would have been bad data in Google Ads. Risk was taken for no reason.
RULE 3 OF 7 · NEVER
🚫
Never enable an automated timer without 24-hour validation
Phase 5 is a gate, not a suggestion
🎯 Think of it like... never setting the automatic coffee maker timer to brew every morning without first brewing one test pot manually to confirm the beans are fresh and the water tastes right. A broken timer making 30 wrong pots is worse than a human making 1 wrong pot.
🩸 The pain that taught us: the original pipeline was running on an automated daily timer from day 1. Nobody ever did the 24-hour verification. 21 days of silent failure resulted.
RULE 4 OF 7 · ALWAYS
Always deep-parse partial failure errors
Walk every error object. Never rely on library shortcuts.
🎯 Think of it like... always reading the fine print on a contract. The big headline says "congratulations you're approved" but the small paragraph on page 4 says "fees apply." The library's "success/fail" count is the headline. The error object's nested details are the fine print.
🩸 The pain that taught us: the old script counted "4 uploaded, 2 failed" but only caught date errors in its parser. The real story was that many of the "4 uploaded" were also silently failing at the attribution layer. The parser was lying because it only checked for errors with a specific field path.
RULE 5 OF 7 · ALWAYS
Always verify timezone handling at system boundaries
Document every system's timezone before writing code
🎯 Think of it like... always double checking the timezone when booking a flight or a video call across state lines. 10 AM Central is 11 AM Eastern is 8 AM Pacific. Getting it wrong once means you miss the meeting entirely. Getting it wrong in code means silent data corruption for weeks.
🩸 The pain that taught us: the UTC-label-mislabel bug burned about 40 percent of all uploads for 21 days straight. Nobody noticed because the code ran "successfully" every night. The bug was invisible until we queried Google's diagnostic.
RULE 6 OF 7 · ALWAYS
Always ask "who is canonical for the linking identifier?"
Start from the system that owns the truth, not the system that needs it
🎯 Think of it like... always asking the shipper where a missing package is, not the delivery driver. The shipper has the canonical record. The driver only has what they were handed. For attribution: Google owns the list of ad clicks, so start with Google's list. Don't start with ServiceTitan and try to reverse-engineer which jobs came from which ads.
🩸 The pain that taught us: we almost built the entire Monday pipeline source-led from ServiceTitan. Would have taken 3 weeks and relied on Ashton's manual lead_source tags. The canonical source (call_view) is 100 times more reliable and was sitting there the whole time.
RULE 7 OF 7 · NEVER · THE BIG ONE
🚫
Never claim "working" from an API 200 OK response alone
Verify end to end or don't claim anything
🎯 Think of it like... never saying "the check cleared" when all you did was cash it. Cashing a check is step 1. Clearing is step 2. They are not the same thing. A teller can hand you money from a check that later bounces. A Google API can return 200 OK for an upload that gets silently dropped at the attribution layer. Both look like "success" at step 1. Both are failures at step 2.
🩸 The pain that taught us — this is THE rule: every other rule on this list exists to enforce Rule 7. 200 OK means "your request had valid JSON and we accepted it for processing." It does NOT mean "your conversion was matched to a real click" or "this conversion will show up in reporting." Those are three entirely separate things that Google validates at three entirely separate stages. 21 days of silent failure happened because we treated 200 OK as "success." Never again. Success is defined as "end-to-end verified in the destination system" and nothing less than that.
🔐
Why a written methodology beats a smart engineer every time
The difference between "Robert will remember this" and "Robert wrote it down and it auto-loads next session" is the difference between one-off and compounding. Memory fades. Written rules don't. Six months from now, when a totally different pipeline breaks in a totally different way, the 6 phases will still fire in the right order, the 7 rules will still catch the same class of mistake, and whoever is debugging (Robert, a future session of Claude, or a new hire) will not have to rediscover anything. That is why the methodology is the real win. Not because we fixed this bug. Because we stopped paying the price for finding it again.
🍳
Monday Morning in Pictures — the Pancake Recipe Analogy
Dry run, test upload, automated timer — explained with breakfast

Robert uses three different technical terms for Monday: "dry run mode", "single test upload", and "automated timer". Imagine you're making pancakes for 50 people at a Sunday brunch. Here is the exact same 3-step process that any cook would naturally use, mapped to what Robert does on Monday.

🥣
Step 1 · 9 AM
Dry Run Mode
= Mixing the batter in the bowl. No heat, no stove, no pancakes yet. Just measure the flour and eggs and see if the batter looks right. Nothing actually gets cooked.
Real version: Robert runs the new code against last week's data and prints every match it would make, but never actually sends anything to Google. Pure practice.
🥞
Step 2 · 10 AM
Single Test Upload
= Cooking one test pancake. Turn on the stove, make exactly one. Taste it. Did the batter actually work? If it's good, the recipe is proven. If it's raw or burnt, stop and figure out why before feeding 50 people.
Real version: Robert sends exactly ONE real call conversion to Google. Watches Google's response. If it comes back clean, the pipeline is proven to work for real.
🍽️
Step 3 · AFTER 2 PM
Automated Timer
= Setting the brunch schedule. Pancakes come out hot every morning at 6 AM without anyone standing over the stove. Only AFTER the test pancake was perfect do you commit to feeding the whole crowd on a schedule.
Real version: Robert enables the daily scheduled job. Every morning at 6 AM, the computer runs the pipeline, uploads yesterday's real calls, feeds Smart Bidding real revenue signal, and nobody has to touch it.
🚫
The golden rule of the Pact
You never skip straight from Step 1 to Step 3. You never tell the cook "just start serving, it'll be fine." You always cook one test pancake first. Even if the dry run looks perfect. Even if you're in a hurry. Even if everyone is hungry. One test pancake, always. That is why Robert will not flip the automated timer on before the 2 PM review confirms the test pancake came out clean.
🗣️
What 2 PM Monday Will Actually Sound Like
If everything goes right, the meeting is 5 minutes long and Robert says almost exactly this

So you know what to listen for, here is the best case scenario script for the 2 PM check-in. Robert will say these exact things in this order. If you hear all five green check marks, the pipe is clean and we turn it on. If you hear any red alarm, we pause and debug instead of shipping.

Robert: "Out of 50 ad calls last week, 37 matched cleanly. That's 74 percent above our 60 percent target. Thirty of those 37 matched within 30 seconds of each other. The matching logic is solid."
Robert: "I sent one test upload at 10 AM using the highest confidence match. Google accepted it with no errors. The response came back clean."
Robert: "I checked Google's settle status a few minutes ago. Our new conversion action shows one pending event, zero alerts, no errors. It'll move to 'successful' within 24 hours based on Google's documented timeline."
Robert: "I have 3 edge cases worth flagging. One customer called twice in 20 minutes about the same job, one call came through a masked area code, and one was on the DST boundary. All 3 are explainable. None of them break the system."
Robert: "Go or no go on enabling the nightly timer?"
You (the team) respond here. If everything above was clean: "Go." If anything sounded off: "No go, let's debug."
🧠

Daniel AI — The Brain Upgrade

NEW APR 11 NIGHT

Daniel is BSP's AI receptionist running on Vapi. For weeks Kalen has flagged that Daniel has "knowledge gaps" — he answers some questions confidently but punts on others, leaving customers with "let me take your name and have our team call you back." Tonight we built the Daniel Brain Upgrade — 4 integrated systems that let Daniel query the entire BSP knowledge library during live calls, learn from every transcript, and get smarter automatically every day without a human writing new prompts. → See GCLID Implementation Cheatsheet

🎯 THE ONE-LINE UPGRADE
Before tonight: Daniel only knew what was hard-coded in his Vapi system prompt (~11,000 characters). After tonight: Daniel can query 9,300+ chunks of BSP knowledge — playbook HTMLs, blog posts, field intel, customer intel, operational brain chunks — mid-call, and speak the top result back to the customer in voice. Plus every call gets analyzed for new gaps, which feed back into the knowledge base for future calls.

🔄 The Full Loop (Diagram)

📞
STEP 1
Customer dials BSP — call routed to Daniel on Vapi
Daniel greets them, listens, tries to match their question against his 11KB system prompt. For standard plumbing questions (clogged drain, water heater out, sewer emergency) the hard-coded prompt is enough.
🤔
STEP 2
Customer asks something outside the prompt
"Do you guys do tankless water heater installs over 11 gallons per minute?" "How much is a camera inspection for a buyer's agent?" "What's your warranty on trenchless sewer repairs?" — questions the hard-coded prompt can't cover because there are infinitely many of them.
🔎
STEP 3
Daniel calls the new search_knowledge_base tool
Daniel's system prompt now includes a Vapi custom tool called search_knowledge_base. He's instructed: "Use this BEFORE saying 'I don't have that information' or 'let me check.'" When the tool fires, Daniel speaks a brief filler line ("Let me look that up for you real quick") while the call happens.
🗂
STEP 4
The Daniel RAG Bridge runs the query against 9,300+ chunks
A new HTTP service on the VM (nexus_daniel_rag_bridge.py on port 8513) receives the query, runs a hybrid text + vector search against the BSP knowledge base in Postgres (titan.knowledge_base), and returns the top 3 most relevant chunks. Every query gets logged to titan.knowledge_gaps so we can see what Daniel is asking about.
📚
STEP 5
4 auto-synced knowledge sources feed the RAG database
The brain isn't a static dump. Four background jobs keep it fresh:
📝 Playbook Indexer
Walks /playbooks/*.html nightly at 5 AM CT. Extracts, chunks, embeds. 4,833 chunks indexed.
🌐 Blog Scraper
Crawls callbrightside.com/blog/ nightly at 4 AM CT via sitemap. Keeps customer-facing content fresh.
🏷 Field Notes + Brain Chunks
Existing pipeline. 2,071 field intel + 484 architectural brain chunks already indexed.
🎙 Customer Intel
962 prior customer conversation intel chunks — so Daniel remembers phrases and concerns real customers have raised.
🗣
STEP 6
Daniel speaks the answer back to the customer in his own voice
The top chunk gets formatted for voice (max ~400 chars, markdown stripped, sentence boundary trimmed) and Vapi turns it into speech on the call. The customer hears Daniel answer directly — no delay, no human transfer. If the tool fails for any reason, Daniel's request-failed message gracefully degrades to "Let me take your name and have our team call you back within 15 minutes" — exactly today's behavior.
🎓
STEP 7
Daily Learning Analyzer extracts knowledge gaps from yesterday's transcripts
Every day at 8 AM CT, the extended nexus_daniel_learning_analyzer.py v2 pulls the last 24 hours of Vapi transcripts, parses each User → AI exchange, pattern-matches on deflective Daniel responses ("I'm not sure", "let me check", "our team will follow up", etc.), and writes the detected gaps to titan.knowledge_gaps with a frequency counter. Any question asked 3+ times becomes a P0 prompt fix for the weekly aggregator. This is the "firm talk" system — but automated and evidence-based.

✅ What Shipped Tonight — 4 Items

🔎
ITEM 1 · DANIEL RAG BRIDGE · LIVE
/opt/nexus/titan/nexus_daniel_rag_bridge.py on port 8513
Hybrid text + vector search against titan.knowledge_base. Vapi tool search_knowledge_base registered and verified live in Daniel's assistant config (7 tools total, was 6). Failure mode is graceful — if nginx proxy isn't wired yet, Daniel falls back to today's "let me have the team call you back" behavior.
🌐
ITEM 2 · BLOG SCRAPER · LIVE
/opt/nexus/titan/nexus_blog_scraper.py · timer: nexus-blog-scraper.timer (daily 4 AM CT)
Discovers blog posts via sitemap.xml, extracts content from <article>/<main> tags, chunks into 1,500-char overlapping segments, embeds via OpenAI text-embedding-3-small, writes to titan.knowledge_base with source_type='blog'. Idempotent (updates existing chunks instead of duplicating).
📝
ITEM 3 · PLAYBOOK INDEXER · LIVE
/opt/nexus/titan/nexus_playbook_indexer.py · timer: nexus-playbook-indexer.timer (daily 5 AM CT)
Walks /opt/nexus/nexus/scripts/output/playbooks/*.html, skips internal audit docs (unmapped_invoices, monday_2pm, debug, test), extracts readable text from <main>/<article>/<body>, chunks, embeds, writes with source_type='playbook'. Currently 4,833 chunks indexed across the full BSP playbook library.
🎓
ITEM 5 · LEARNING ANALYZER v2 · LIVE (integrated into existing timer)
/opt/nexus/nexus/scripts/nexus_daniel_learning_analyzer.py · timer: nexus-daniel-learning.timer (daily 8 AM CT, existing)
Extended the existing Daniel daily learning system instead of building a parallel script. v2 adds: pulling last 24h Vapi transcripts, parsing User→AI exchanges, pattern-matching 11 deflective response patterns, writing gaps to titan.knowledge_gaps with frequency counter, surfacing top 10 in the daily brain chunk for the weekly prompt aggregator. The "firm talk" system Robert committed to in Slack — but automated and continuous.

📊 Daniel's State as of Tonight

Vapi Tools
7
was 6 · +search_knowledge_base
RAG Chunks
9,300+
across 22 source types
Playbook Chunks
4,833
full BSP library, auto-synced daily
Daily Calls Analyzed
153
cumulative · 59 last 7 days
⚠ ONE MANUAL STEP BLOCKS FULL PRODUCTION
The search_knowledge_base tool is registered in Daniel's Vapi config pointing at https://morpheus.callbrightside.com/daniel/search. That URL requires an nginx proxy block routing /daniel/ to localhost:8513. Until that block is added (part of Checklist #3 in the handoff below), Daniel will attempt the tool and gracefully fall back to the "let me take your info" behavior. Zero regression vs today's Daniel — the worst case is Daniel behaves exactly as he does now. Add the nginx block and Daniel starts answering 4,833 playbook worth of new questions automatically.
📋

Monday Morning Handoff — 3 Click-by-Click Checklists

EXECUTE BEFORE 2 PM

Three final handshakes need to happen for the full GCLID-to-Revenue pipeline to be bullet-proof by Monday at 2 PM. Every step below is click-by-click with exact paths, button labels, and verification. If a step takes more than 2 minutes, something is wrong — stop and flag it in Slack.

⚠️ IMPORTANT CONTEXT
ServiceTitan's public API does NOT support programmatic creation of custom field definitions. This is a Settings-level administrative task that must be performed manually through the ST UI. The Nexus webhook listener ALREADY knows how to read a custom field named GCLID, so all you need to do is create the field in the UI and the pipeline picks it up automatically.
CHECKLIST 1 OF 3 · SLACK VERIFICATION
Verify Big Sale & Lost Sales alerts are firing to the right channels
Estimated time: 3 minutes · Owner: Robert
The listener is wired to fire alerts via the existing SLACK_WEBHOOK_URL as a shared fallback for both #big-sale-alert and #lost-sales. Two test pings were fired Saturday 22:35 UTC and 22:38 UTC. First verify those arrived, then optionally create dedicated webhook URLs for the two channels if you want them routed separately.
▶ STEP 1.1 — Verify the test pings arrived
  1. Open Slack desktop or web app, log in as Robert
  2. Click on the channel that BSP currently uses for notifications (likely #nexus-alerts, #general, or whichever channel the SLACK_WEBHOOK_URL was originally bound to)
  3. Look for two messages timestamped around 22:35 and 22:38 UTC on Apr 11, 2026:
    • First ping: "🔧 Nexus webhook smoke test at 22:35 UTC..."
    • Second: "🔥 BIG SALE $7,500.00 · Sewer Main Replacement · gclid_source=phone_fallback:9135550000:@now"
  4. If both messages are visible: alerts are firing correctly. Skip to STEP 1.3 (optional dedicated webhooks) or stop here.
  5. If you can't find them anywhere: the existing webhook URL may have been revoked. Follow STEP 1.2 to mint fresh webhooks.
▶ STEP 1.2 — Mint dedicated webhook URLs (optional but recommended)
Do this if you want big sales and lost sales to land in different Slack channels instead of sharing one.
  1. In Slack, click the BSP workspace name (top left) → Tools & settingsManage apps
  2. New browser tab opens to https://[workspace].slack.com/apps. In the search bar, type "Incoming Webhooks" and click the first result
  3. Click the green "Add to Slack" button
  4. Dropdown appears: Post to Channel. Select #big-sale-alert. Click "Add Incoming WebHooks integration"
  5. Next page shows a Webhook URL like https://hooks.slack.com/services/T.../B.../.../. Copy it to clipboard
  6. Scroll down, optionally change the display name to Nexus Big Sale Bot and the icon to 🔥. Click Save Settings
  7. Repeat steps 3-6 for #lost-sales, naming that one Nexus Lost Sale Bot with icon ❄️
  8. You now have 2 dedicated URLs. Proceed to STEP 1.3
▶ STEP 1.3 — Paste the dedicated URLs into the VM env file
  1. SSH to the VM: ssh -i ~/.ssh/google_compute_engine dovew@34.55.179.122
  2. Edit the env file: sudo nano /opt/nexus/nexus/config/.env
  3. Find the two lines that currently read:
    SLACK_BIG_SALE_WEBHOOK=https://hooks.slack.com/services/T05NXFBH0PR/...
    SLACK_LOST_SALES_WEBHOOK=https://hooks.slack.com/services/T05NXFBH0PR/...
  4. Replace each URL with the dedicated one you copied in STEP 1.2
  5. Ctrl+O to save, Enter to confirm, Ctrl+X to exit nano
  6. Restart the listener: sudo systemctl restart nexus-st-webhook.service
  7. Verify it's active: systemctl is-active nexus-st-webhook.service (should print active)
  8. Fire a test: curl -s http://localhost:8511/health (should return JSON with "status":"ok")
  9. Check that both channels get the next real invoice alert from Ashton's next invoice post
✓ Success criteria: Within 60 seconds of Ashton posting a $5,000+ invoice in ServiceTitan, a 🔥 BIG SALE message appears in #big-sale-alert showing the dollar amount, job type, and GCLID attribution source (or None if no GCLID was captured for that customer yet).
🏷
CHECKLIST 2 OF 3 · SERVICETITAN GCLID CUSTOM FIELD
Create the GCLID field on Job records — manually, via ST UI
Estimated time: 2 minutes · Owner: Robert (or Kalen with admin access)
⚠ WHY THIS HAS TO BE MANUAL
ServiceTitan's public API does not currently support programmatic creation of new custom field definitions. Field creation is a Settings-level administrative task that must be performed through the ST UI. I attempted both /settings/v2/custom-fields and /jpm/v2/custom-fields endpoints — both return 404 for BSP's tenant. This is ST's intended design, not a misconfiguration on our side.
▶ STEP 2.1 — Navigate to Custom Fields settings
  1. Log into ServiceTitan at app.servicetitan.io as Robert (admin role required)
  2. Click the ⚙ gear icon in the top-right corner — that opens Settings
  3. In the left sidebar, navigate to OperationsCustom Fields
    Full path: Settings > Operations > Custom Fields
  4. You'll see a list of existing custom fields grouped by object type (Customer, Location, Job, Invoice, etc.)
  5. Click the "+ Add New" button (usually top right of the Custom Fields list)
▶ STEP 2.2 — Fill in the field definition exactly
NameGCLID
Data TypeText (single-line)
Apply ToJob
RequiredNo (unchecked)
ActiveYes (checked)
Max Length256 (or leave default)
DescriptionGoogle Click Identifier captured from ad clicks via Snippet #55 and the Nexus GCLID Bridge. Used by nexus_st_webhook_listener to fire uploadClickConversions on invoice post. Do NOT hand-edit this field unless Robert or Kalen has authorized it.
▶ STEP 2.3 — Configure visibility / permissions
  1. Scroll down to the "Visibility" or "Permissions" section of the custom field editor
  2. Set the following role-based permissions:
    • Office / Admin roles (Ashton, Kalen, Stephanie, Robert, CSR): Read + Write
    • Technician / Field roles (all plumbers, install techs): HIDDEN — not just read-only, fully hidden from ST Mobile
    • API access: Read + Write (this is what lets our webhook listener + bridge populate it)
  3. Why hidden-from-techs: techs don't need to see it, and hiding it from ST Mobile keeps the field form clean
  4. Click Save at the bottom of the editor
▶ STEP 2.4 — Verify by reading one existing job
  1. Open any recent ST job (Dispatch → Today → click any job row)
  2. Scroll to the Custom Fields section of the job detail view
  3. Confirm the new GCLID field appears, empty, with an input box
  4. Paste a test value like TEST_GCLID_apr11 and click Save
  5. Within 30 seconds, the Nexus webhook listener should log a job.updated event with the GCLID. Verify via SSH:
    ssh -i ~/.ssh/google_compute_engine dovew@34.55.179.122 \
      "grep TEST_GCLID_apr11 /opt/nexus/nexus/scripts/output/st_webhook.log | tail -5"
  6. If the grep finds the value, the field-to-listener path is working end-to-end. Clear the test value from the job before closing.
✓ Success criteria: The GCLID field is visible on the Job form for Ashton and invisible to techs in ST Mobile. Webhook listener log confirms it receives the field value on any job edit.
🌐
CHECKLIST 3 OF 3 · WORDPRESS SNIPPET + NGINX PROXY
Wire Snippet #55 to POST captures to the Nexus bridge
Estimated time: 8 minutes · Owner: Robert
The bridge is live at localhost:8512 on the VM but isn't reachable from callbrightside.com yet. Two pieces need wiring: the nginx reverse proxy (so callbrightside.com/gclid/capture routes to port 8512) and the Snippet #55 JavaScript on the WordPress landing pages (so ad clicks actually POST to the bridge).
▶ STEP 3.1 — Add the nginx reverse proxy block
  1. SSH to the VM: ssh -i ~/.ssh/google_compute_engine dovew@34.55.179.122
  2. Edit the nginx config for morpheus: sudo nano /etc/nginx/sites-available/morpheus.callbrightside.com
  3. Inside the server { ... } block that handles HTTPS on port 443, add this new location block BEFORE the closing brace:
    # GCLID bridge — Snippet #55 capture receiver (Task #16)
    location /gclid/ {
        proxy_pass http://127.0.0.1:8512/gclid/;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Origin $http_origin;
        proxy_read_timeout 10s;
        proxy_connect_timeout 5s;
    
        # CORS preflight — the bridge handles CORS in-code but we pass through
        if ($request_method = 'OPTIONS') {
            add_header Access-Control-Allow-Origin "$http_origin";
            add_header Access-Control-Allow-Methods "POST, OPTIONS";
            add_header Access-Control-Allow-Headers "Content-Type";
            add_header Access-Control-Max-Age 86400;
            return 204;
        }
    }
    
    # ST webhook listener — ServiceTitan → Nexus (invoice events)
    location /st/webhook {
        proxy_pass http://127.0.0.1:8511/st/webhook;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Titan-Signature $http_x_titan_signature;
        proxy_read_timeout 10s;
    }
  4. Ctrl+O, Enter, Ctrl+X to save and exit
  5. Test the nginx config for syntax: sudo nginx -t (must print syntax is ok and test is successful)
  6. Reload nginx without downtime: sudo systemctl reload nginx
  7. Verify the bridge is reachable from outside:
    curl -s https://morpheus.callbrightside.com/gclid/health
    Should return JSON with "status":"ok". If it 404s, nginx didn't pick up the new block — re-check the file location and run the reload again.
▶ STEP 3.2 — Update WordPress Snippet #55 to POST captures
  1. Log into WordPress admin at callbrightside.com/wp-admin as Robert
  2. In the left sidebar, click Snippets (Code Snippets plugin)
  3. Find Snippet #55 — "CRO form v4.0" (or whatever name it has). Click Edit
  4. At the BOTTOM of the existing JavaScript in the snippet (before any closing script tags), paste this new capture-and-POST block:
    // ============================================
    // Nexus GCLID Bridge capture (Task #16)
    // Fires on form submit — POSTs capture to VM
    // ============================================
    (function() {
      const BRIDGE_URL = 'https://morpheus.callbrightside.com/gclid/capture';
    
      function getParam(name) {
        const m = new RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
        return m ? decodeURIComponent(m[1]) : null;
      }
    
      // Capture GCLID from URL on page load and stash in sessionStorage
      const urlGclid = getParam('gclid');
      if (urlGclid) sessionStorage.setItem('bsp_gclid', urlGclid);
      const urlWbraid = getParam('wbraid');
      if (urlWbraid) sessionStorage.setItem('bsp_wbraid', urlWbraid);
      const urlGbraid = getParam('gbraid');
      if (urlGbraid) sessionStorage.setItem('bsp_gbraid', urlGbraid);
    
      // Hook the CRO form submit — send capture to bridge
      document.addEventListener('submit', function(e) {
        const form = e.target;
        if (!form || !form.matches('form')) return;
    
        // Only fire for forms on landing/service pages with phone inputs
        const phoneInput = form.querySelector('input[name*="phone" i], input[type="tel"]');
        if (!phoneInput) return;
    
        const payload = {
          gclid: sessionStorage.getItem('bsp_gclid') || null,
          wbraid: sessionStorage.getItem('bsp_wbraid') || null,
          gbraid: sessionStorage.getItem('bsp_gbraid') || null,
          phone: phoneInput.value,
          email: (form.querySelector('input[type="email"]') || {}).value || null,
          name: (form.querySelector('input[name*="name" i]') || {}).value || null,
          landing_page: window.location.pathname,
          referrer: document.referrer || null,
          utm_source: getParam('utm_source'),
          utm_medium: getParam('utm_medium'),
          utm_campaign: getParam('utm_campaign'),
          session_id: sessionStorage.getItem('bsp_session') || null,
          form_submitted_at: new Date().toISOString()
        };
    
        // Only post if we have at least a gclid or phone
        if (!payload.gclid && !payload.wbraid && !payload.gbraid && !payload.phone) return;
    
        // Fire-and-forget POST (don't block the form submit)
        try {
          navigator.sendBeacon(BRIDGE_URL, new Blob([JSON.stringify(payload)], { type: 'application/json' }));
        } catch (err) {
          // Fallback for browsers without sendBeacon
          fetch(BRIDGE_URL, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload),
            keepalive: true
          }).catch(function() {});
        }
      }, true);
    })();
  5. Click Update at the top of the snippet editor
  6. Make sure the snippet's Run everywhere or Run on landing pages toggle is ON
  7. Purge WordPress cache (WP Rocket → Clear Cache), Cloudflare cache (dashboard → Caching → Purge Everything), and LiteSpeed cache if present
▶ STEP 3.3 — End-to-end smoke test
  1. Open an incognito/private browser window
  2. Navigate to https://callbrightside.com/sewer-camera-inspection/?gclid=TEST_MANUAL_12345&utm_source=google&utm_medium=cpc&utm_campaign=manual_test
  3. Fill in the form: Name: Test Robert, Phone: (913) 000-1111, Email: test@example.com
  4. Submit the form (pick any service type)
  5. The form will behave normally. In the background, Snippet #55 fires a beacon POST to the bridge
  6. SSH to VM and verify the capture arrived:
    ssh ... "tail -5 /opt/nexus/nexus/scripts/output/gclid_bridge.log"
    ssh ... "psql -h localhost -U robert -d bsp_analytics -c \"SELECT * FROM titan.gclid_captures WHERE gclid='TEST_MANUAL_12345'\""
  7. You should see a new row with the test phone and the TEST_MANUAL_12345 GCLID. If yes, Snippet #55 is wired end-to-end
✓ Success criteria: An incognito form submit on callbrightside.com with a manually-added ?gclid= URL param results in a new row in titan.gclid_captures within 5 seconds, containing the GCLID, phone, UTM fields, and landing page path.
🎯 MONDAY 2 PM CHECK — THE COMPLETE HANDSHAKE
Once all three checklists are complete, the full GCLID-to-Revenue handshake is live in production. The test to confirm:
  1. Click an active BSP Google Ad in an incognito browser → lands on callbrightside.com/sewer-camera-inspection/ with a real GCLID in the URL
  2. Fill out the form with your real phone and submit → Snippet #55 beacons the capture to the bridge → row appears in titan.gclid_captures
  3. Call the office, Ashton books a mock job in ST for the same phone number → ST fires job.created webhook → listener logs it
  4. Mark the mock job Complete, add a test invoice for $1 to a fake customer, post and mark Paid → ST fires invoice.paid webhook with terminal status
  5. Listener phone-matches the capture from step 2 → fires uploadClickConversions to Google Ads with the $1 value → logs success in titan.google_ads_conversion_log
  6. 24 hours later, check Google Ads → Tools → Conversions → "ST Call Completed (API)" → you'll see 1 conversion credited to the real GCLID
That's the trust rebuild. From ad click to real invoice to Google Ads Smart Bidding signal, every step owned by BSP, every number with a receipt, every failure visible in the shadow log. No WhatConverts subscription. No platform migration. Just the right hooks in the right places.
🤝

The Pact for Monday

HOW WE WORK

These are the four rules Robert and the team agreed on for Monday's build. Nothing happens outside these rules. This is how we stay honest, how we avoid another silent failure, and how we make sure the team sees the real numbers whether they are good or bad.

🚦
No timer before the proof. The automated upload job stays off until the 2 PM review passes. We verify with real data before we trust it.
1️⃣
Single test first. Before uploading in batches, we upload one call. If that clean single event fails, we stop and debug instead of flooding the system.
📖
Honest reporting. If the numbers are low or errors appear, Robert reports them exactly as returned. No sugarcoating, no "close to working," no optimism math.
🎯
Your call on go or no go. Robert presents data. The team makes the production decision. Robert does not flip the switch unilaterally.
🏆

The Real Win From Last Night

BIGGER PICTURE

Seven Google Ads changes and one code patch is the visible win. The invisible win is much bigger and matters much more. Here is the thing Kalen and Stephanie should both know.

💡 What we actually learned
We had a silent failure that hid for 21 days. Not because the team did anything wrong, but because Google Ads accepted our uploads with a 200 OK response while dropping them quietly in the background. That kind of bug is invisible unless you know exactly where to look. Last night we built a step by step protocol so that this class of bug can never hide for 21 days again. Any future pipeline work, any future Google Ads debug, any future ServiceTitan integration, follows the same 6 phase process. That protocol is the real win. The seven fixes pay off in the next 2 weeks. The protocol pays off for years.
🔗

Related Documents

QUICK LINKS

This document is part of the Monday standup packet. Here are the related pieces so the team can jump between them without hunting.

📊
Sacred HTML v2 — Monday Standup
The weekly business review doc
Revenue health, call performance, Google Ads status, owner accountability, Kill/Keep/Start experiments, Nick investigation, Plaid setup instructions. The full picture for Monday.
→ morpheus.callbrightside.com/documents/BSP_Sacred_HTML_v2.html
⚔️
Website Platform Battle Plan
Bricks migration strategy
The full strategic document for the website migration. Audrey's workspace, Figma pipeline, Unbounce bare minimum pages, Robert's build timeline. Stephanie's format test bed.
→ morpheus.callbrightside.com/documents/BSP_Website_Platform_Battle_Plan.html
🎯

GCLID Implementation Cheatsheet

5-HOP CHAIN · BSP-SPECIFIC
⚡ SHORT VERSION · OPEN ANY HOP BELOW FOR THE TECHNICAL DETAIL
Does GCLID work? PHONE: yes ✅ · FORM: 2 of 6 hops degraded ⚠️
📞 PHONE PIPELINE · 90% of revenue
🟢 Google Ad click → 🟢 GFN dial → 🟢 call_view log → 🟢 3CX SQLite
                        ↓
🟢 Fuzzy-match (area+5min, ≥0.7 conf) → 🟢 65-min UTC buffer → 🟢 uploadCallConversions API
                        ↓
🟢 Smart Bidding sees real $762 (was $1) · 🛡️ #lab Slack heartbeat on next failure

📝 FORM PIPELINE · 10% of revenue
🟢 Google Ad click → ❓ Snippet #55 hidden field❓ Bridge POST
                        ↓
🟢 ST custom field 59590012 (gclid) + 59583360 (booking) → ⚠️ ST webhook fires
                        ↓
🟢 nexus_st_webhook_listener.py → uploadClickConversions API
Stephanie cutIn one line
🚨 ProblemGoogle Ads can’t see real BSP revenue per click without a 5-hop pipeline; one bad hop = silent attribution loss.
💰 Impact$226K/wk Big Sale revenue → $204K/wk depends on phone pipeline (now ✅); $22.7K/wk on form pipeline (⚠️).
💡 SolutionApr 26 source-side patch + #lab Slack heartbeat + parser forensic backup. Phone path closed end-to-end.
📊 DataApr 26 —single-test: 1 of 1 success. Action ID 7571216327. Caller +18164371250 / $762 conv value verified.
🎯 Need2 form-side decisions: (a) Nikhil time slot for Snippet #55 reimplementation; (b) ST Developer Portal webhook registration audit.
HopWhat it isStatus
🎯 Hop 1Snippet #55 Capture & Hidden Field❓ forms · n/a phones
🌉 Hops 2 & 3Custom Bridge → ServiceTitan custom fields✅ endpoint · ⚠️ starved on form-side
⚡ Hops 4 & 5Google Ads API upload (calls + clicks)✅ phones · ⚠️ forms (no events flowing)
📊 click_viewGAQL queries against Google’s call log✅ 16 calls retrieved Apr 26
📞 3CXSQLite call_log fuzzy-match (area+5min)✅ 6 of 16 matched at 0.8 conf
🔌 ST Webhookinvoice.updated → click upload⚠️ listener up, no real events
👻 GhostsBlocklisted action IDs (179 / 881M / 7.5B)✅ gaql_safe.py rejects them
🤖 Smart BiddingApr 11–26 LEARNING_COMPOSITION_CHANGE✅ hands-off window ended Apr 26
🔍 Silent failuresApr 26 source-side deep-parse + #lab heartbeat✅ patched + parser deployed

The full attribution pipeline at Bright Side spans five hops: WordPress capture, custom bridge service, ServiceTitan storage, ST webhook trigger, and Google Ads API upload. Each hop is a place a silent failure can hide. This cheatsheet documents the BSP-specific wiring at every hop, the scar tissue from incidents that already happened, and the structural defenses now in place. Cross-link upward to Brain Chunk 1 (Five-Variation Diagnostic Methodology) for the same family of insight applied to other failure modes.

🎯 Hop 1 — Snippet #55 Capture & Hidden Field ▾ tap to expand

Custom CRO contact form on callbrightside.com. Hand-rolled, NOT Forminator, NOT a standard WordPress plugin. Lives as a Code Snippet in the WP Code Snippets plugin. Capture pattern: URL parameter ?gclid=... on first pageview is dual-written to a 90-day cookie AND sessionStorage so a cache flush on either side does not blank the GCLID. Hidden field <input type="hidden" name="gclid"> is repopulated on form-render from whichever store has it. On submit, form POSTs to bridge port 8503 / 8509 with the GCLID payload.

Scar tissue Mar 22, 2026: a phpMyAdmin operation wiped active Code Snippets including #55 (CRO form) and #39 (GCLID capture). No alert fired. Eleven-day silent blackout. Forms still rendered (Bricks placeholder picked up the slack on the front end), but every submission landed without a GCLID. Discovered Apr 2 during a routine Google Ads audit.

Heartbeat fix: if the bridge service receives a POST with valid form payload but null GCLID at a frequency above the established baseline, fire an immediate Slack alert to #lab. Mirrors the _post_silent_failure_to_lab pattern shipped to nexus_uploadcallconversions.py on Apr 26. Verification step: grep inbound bridge logs for ?gclid= parameter on every form POST; if frequency drops to zero suddenly, that is the canary.

🌉 Hops 2 & 3 — Custom Bridge → ServiceTitan ▾ tap to expand

Bridge service runs on the Nexus VM at port 8503 (primary) and 8509 (failover). Receives form POST with GCLID, looks up matching ST customer record, writes the GCLID into ST custom field 59590012 and the booking method into custom field 59583360 on the matching Job record (NOT Customer or Location — Job is the canonical attachment because it is what the invoice eventually links to).

Risk: ServiceTitan API can overwrite custom fields on subsequent updates if the writer does not use explicit field-preserving PATCH semantics. Each bridge write must read the current job record, merge in the new custom field values, and PATCH the merged set — never PUT a partial body.

Verification: bridge service logs the ST job_id alongside the GCLID for every write so ghost jobs (jobs that lost their attribution downstream because of an overwrite) can be traced by joining bridge logs to ST audit history. Cross-link upward to Brain Chunk 5 (Master Protocol) for the read-merge-patch discipline.

⚡ Hops 4 & 5 — Google Ads API Upload (Two Parallel Pipelines) ▾ tap to expand

Phone calls (90% of revenue): uploadCallConversions via nexus_uploadcallconversions.py. Conversion action ID 7571216327 ("ST Call Completed (API)"). Pulls Google Ads call_view for the lookback window, fuzzy-matches against the 3CX call_log SQLite database, uploads matched calls. Live since Apr 13, 2026. See the existing Monday Pipeline section above for the full architecture; do not restate here.

Web forms (10% of revenue): uploadClickConversions via nexus_st_webhook_listener.py. Listens for ST invoice.updated webhooks. On each event, reads the GCLID from custom field 59590012, posts a click conversion to Google Ads API. Live since Apr 12, 2026. Triggered automatically by ST when an invoice transitions to paid.

📊 click_view — GAQL Query Patterns ▾ tap to expand

click_view is the canonical Google Ads source for tying paid clicks to downstream conversions. The Monday Pipeline starts every fuzzy-match cycle with a click_view query. Standard query shape:

SELECT
  call_view.caller_country_code,
  call_view.caller_area_code,
  call_view.start_call_date_time,
  call_view.call_duration_seconds,
  campaign.name,
  ad_group.name
FROM call_view
WHERE call_view.start_call_date_time > '{lookback_start}'

Note: BSP is NOT on Campaign Manager 360. CM360 query patterns do not apply here. Reference nexus_uploadcallconversions.py for the live implementation.

📞 3CX call_log Fuzzy-Match (BSP-Unique) ▾ tap to expand

3CX SQLite call_log is the BSP-side source for inbound caller_id and call_start_time. Match logic: area-code prefix (913 or 816), 5-minute window tolerance against the call_view timestamp, confidence score 0.0 to 1.0 based on time delta. Production threshold: confidence >= 0.7. Below that routes to manual review queue.

65-minute Date Gate UTC buffer: 3CX stores in UTC, call_view returns in account-local America/Chicago. Conversion upload timestamps must be UTC-aware with explicit offset, AND must subtract a 65-minute safety buffer to avoid Google rejecting events as LATER_THAN_MAXIMUM_DATE due to clock drift between the VM and Google's servers. Cross-link upward to Brain Chunk 3 (Timezone Bug Pattern) and Bug #2 forensic for the underlying incident.

🔌 nexus_st_webhook_listener.py — invoice.updated Path ▾ tap to expand

File: /opt/nexus/nexus/scripts/nexus_st_webhook_listener.py. Service: nexus-st-webhook.service. Listens on the public webhook endpoint for ST invoice.updated events, parses the invoice JSON, looks up the GCLID from the attached job's custom fields, and fires uploadClickConversions to Google Ads.

Apr 14 fix: heartbeat alert routing was firing CRITICAL and WARNING alerts to the Big Sale Slack channel instead of #lab, violating the WINS-ONLY rule. Patched to stdout-only for both severities. Apr 15 fix: listener INSERT was using a non-existent column name created_at instead of received_at; every webhook returned 200 OK at HTTP layer but failed silently at DB INSERT. Patched. Manual probe verified end-to-end after fix.

👻 Ghost Action ID Blocklist ▾ tap to expand

Three known ghost conversion action IDs that must never be cited as upload targets: 179, 881920526, 7537150978. The gaql_safe.py validator (shipped as one of the five structural fixes Apr 11-12) blocks any GAQL query referencing them. To extend the blocklist, edit GHOST_ACTION_IDS in /opt/nexus/titan/gaql_safe.py; every Google Ads query in the project routes through safe_query() which raises before the API call if a ghost is referenced. Cross-link to Phantom Numbers RCA above for the underlying incident.

🤖 Smart Bidding LEARNING_COMPOSITION_CHANGE ▾ tap to expand

Apr 11, 2026 evening: Sewer campaign entered LEARNING_COMPOSITION_CHANGE state after the team created a new primary action ("ST Call Completed (API)") and demoted two stale primaries (duplicate Website Contact Form + dead EC4L customer match). Goal composition changes trigger a 7-14 day re-learning window during which Smart Bidding rebuilds its model against the new signal mix.

Kalen's hands-off rule: until the Apr 26 lock date, no further bid strategy changes, no target CPA/ROAS shifts beyond +/- 20%, no significant budget moves, no further conversion goal additions or demotions. Apr 26 is the end of the learning window; CPL trend should show 30-50% improvement on Sewer post-relearn if the rebuild succeeded. Cross-link upward to Foundation #3 (Smart Bidding Learning Flywheel) for the underlying mechanism.

🔍 Debugging Silent Failures — The Partial Failure Paradox

The paradox: Google Ads API returns HTTP 200 OK at the transport layer. Top-level partial_failure_error.code == 0 at the response summary level. But individual rows in response.results[] are empty placeholders for failed rows. The library's "success/fail" count looks clean. Real signal lives in partial_failure_error.details[] as binary-protobuf-encoded GoogleAdsFailure messages. If your code only checks the top-level code, all per-row errors hide silently. Source: developers.google.com/google-ads/api/docs/best-practices/partial-failures.

Two layers of defense — both shipped Apr 26, 2026:

DEBUG logging YAML config — drop in /opt/nexus/nexus/config/google-ads.yaml:

logging:
  version: 1
  disable_existing_loggers: False
  formatters:
    bsp_fmt:
      format: '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
  handlers:
    google_ads_debug_file:
      class: logging.FileHandler
      filename: /opt/nexus/nexus/scripts/output/logs/google_ads_debug.log
      formatter: bsp_fmt
      mode: a
  loggers:
    "google.ads.googleads.client":
      level: DEBUG
      handlers: [google_ads_debug_file]
      propagate: False

Important gotcha: the project loads the GoogleAdsClient via load_from_dict(), NOT load_from_storage(). The YAML's logging block does NOT auto-apply when load_from_dict is used. The block must be applied explicitly via logging.config.dictConfig() AFTER logging.basicConfig() in the script. Without that explicit step, the YAML is inert.

The official deserialize pattern (canonical Python):

partial_failure = getattr(response, "partial_failure_error", None)
if int(getattr(partial_failure, "code", 0)) != 0:
    for error_detail in partial_failure.details:
        failure_message = client.get_type("GoogleAdsFailure")
        GoogleAdsFailure = type(failure_message)
        failure_object = GoogleAdsFailure.deserialize(error_detail.value)
        for error in failure_object.errors:
            index = error.location.field_path_elements[0].index
            code_field = error.error_code.WhichOneof("error_code")
            code_value = getattr(error.error_code, code_field, None)
            print(f"Row {index}: {code_field}={code_value} — {error.message}")

Two more gotchas (verified Apr 26):

The Big Four error codes glossary:

Reference fixture — synthetic 3-conversion failure shape (Apr 26, 2026): the Apr 25 organic 0-of-5 incident pre-dated DEBUG logging, so no real proto3 dump exists for it. The next organic failure caught by the Apr 26 source-side patch will be appended here as real scar tissue. Fixture below was used to validate parse_google_ads_debug.py against the documented partial_failure response schema.

{
  "request_count": 3,
  "result_summary": {"successful": 1, "failed": 2},
  "failures": [
    {
      "row_index": 1,
      "caller_id": "+1913555XXXX",
      "call_start_date_time": "2026-01-15 09:15:00-05:00",
      "conversion_action": "customers/7269555791/conversionActions/7571216327",
      "conversion_value": "250",
      "result_populated": false,
      "status": "failure",
      "partial_failure_error_present": true,
      "request_id": "FIXTURE-REQUEST-ID-ABC123"
    },
    {
      "row_index": 2,
      "caller_id": "+1913555YYYY",
      "conversion_action": "customers/7269555791/conversionActions/9999999999",
      "result_populated": false,
      "status": "failure",
      "partial_failure_error_present": true,
      "request_id": "FIXTURE-REQUEST-ID-ABC123"
    }
  ],
  "successes": [
    {"row_index": 0, "caller_id": "+1913555ZZZZ", "result_caller_id": "+1913555ZZZZ", "status": "success"}
  ]
}

Cross-links: Brain Chunk 1 (Five-Variation Diagnostic Methodology) above for the same family of insight applied to the Apr 10 EC4L diagnostic. Bug #2 forensic above for the original 21-day silent failure that motivated the entire attribution rebuild.