Operations Reference · Audited 25 Aug 2026

Atlas :: Phase 3

How the business actually runs, told in the order the work happens.

35 workflows 6 acts 6 databases 6 Apps Scripts 8 browser automations

<!-- ========================================================== EDITING THIS FILE? IT HAS FOUR COPIES.

  1. atlas.md <- you are here; the only hand-edited one
  2. atlas.html <- python3 build-atlas-html.py
  3. Atlas.pdf <- rebuild-atlas-pdf.cmd (or print the HTML)
  4. the claude.ai page <- publish atlas.html to the SAME url: https://claude.ai/code/artifact/626e5bb3-67a4-45e8-a389-19106ca503a9

Step 4 is the one that gets forgotten and it is the copy the operator reads. If you cannot publish, SAY SO in your reply rather than leaving it silently stale. Publishing without that url creates a second Atlas.

Never hand-edit atlas.html — it is generated AND designed. Full procedure: Designs/system/README-ATLAS.md ========================================================== -->

How the business actually runs, told in the order the work happens.

Who this is for

This document has two readers, and the second one shapes how it is written.

The operator, who needs to find how something works without opening n8n.

Cipher, who needs to already know. The Atlas is the context layer that lets a sentence like "the scheduling workflow is acting up" land with everything attached — what it touches, what it depends on, what breaks when it fails — with no re-explaining. That is the difference between an assistant who must be briefed each session and one who shares the operator's picture of the system.

Three other components carry that context, and the Atlas is the index they orbit:

Obsidian (the app)
where the vault's files live
Graphify
connects Obsidian's files into a navigable graph
Vector stores
let Cipher query the graph semantically
Atlas
the index that says what each part of the system does

What that means for how this is written. A conversation is never about one thing. It is about ten things that turn out to bear on the one, so a chapter has to give Cipher a position on the map, not an island — dense in its own facts, rich in its edges.

Retrieving a chapter should reveal its neighbourhood: what feeds it, what it feeds, what shares its dependencies. That is what lets her say "what you're describing is southwest of here" rather than only answering the question asked. Depends on and Connects to are therefore not conveniences — they are the navigational surface, and the difference between an assistant who answers and one who knows where the answer sits.

Two consequences: chapters stay whole rather than split, so a retrieved chunk is never half a thought. And anything no longer true is removed rather than annotated — a stale fact retrieved confidently is worse than no fact at all.

The three phases

The business was built in three passes, and the order explains the shape of everything here — including what this document cannot see.

Phase 1GoHighLevelHand-built workflows, before the medium was right. Slow to build, slow to change. 76 still live there — 56 active, 20 inactive.
Phase 2n8nThe automation layer. Built once AI made the medium fast enough to design a business rather than assemble one. This Atlas is the Phase 2 map.
Phase 3ConsolidationCurrent. Pull the GHL workflows home; wire Obsidian, Graphify and vector stores against this document; myCRM SIM; move off Sheets to Postgres — n8n side COMPLETE 2026-08-24; Apps Scripts still to rebuild.

Why this matters for reading the Atlas. This document maps 35 workflows. GoHighLevel holds 76 more — 56 of them active. So roughly 62% of the estate is not in here, and that is not an oversight — they are Phase 1 artefacts living somewhere n8n cannot see.

(n8n reports 36 active workflows, not 35. The extra is Trading Sentiment, which belongs to the trading strategy rather than the wholesaling pipeline and has no chapter here by design. Two joined on 2026-08-24/25: SB: SECTION HEALTH, which replaced the Apps Script rollups and is the first live writer section_health has ever had, and SB: CIPHER SQL, the authenticated gateway that lets Cipher read and write Postgres directly instead of through a web app serving a frozen sheet.) Wherever a chapter says a failure is "already caught on the GoHighLevel side," it is describing the seam between phase one and phase two. Phase 3 closes that seam, and each workflow that comes home gains a chapter here as it lands.

But 56 workflows do not become 56 chapters. GoHighLevel allows only a fixed set of tools, so each workflow there does one action — that was the platform's ceiling, not a design choice. n8n has no such ceiling, and the existing estate already shows what happens when the ceiling lifts: SB: TXT IND is 87 nodes and ten switches in a single workflow; SB: DATA VER routes to four destinations through thirteen.

So the migration consolidates rather than copies. Attempted Contact 2 is seven GHL workflows today — email, SMS generator, SMS post-call, auto outbound, manual outbound, post-call, voicemail — and becomes one n8n workflow with a switch, the same shape as the inbound side already has.

Book Two therefore lands at roughly 15–25 chapters, not 56. The Atlas grows by about half. Chapter numbers stay a reading aid rather than an address, because even that much insertion renumbers everything after it.


How the whole thing works

Buyers tell us where to go. We find cash buyers on Facebook, resolve the ZIPs they mention into counties, and propose the counties worth entering. Nothing auto-fires — a proposal is a suggestion a human approves.

Once a county is onboarded, we go get the property records. Fifty-two scheduled jobs drive a single browser automation through that county's clerk portal, pulling probate, lis pendens, divorce, liens and judgments. A vision model reads each document; the address is resolved, the record validated, the owner skip-traced into a phone number and email. Anything that fails lands in a named bucket with a reason — nothing is silently dropped.

Clean leads stage on Prep, are enriched and split by TPS, and at 5:45 each morning the importer pushes the qualified ones from Master to GoHighLevel — capped by a number the operator agreed at the 5:20 brief. GHL runs the outreach. Master is the handoff boundary: everything before it is ours, everything after is theirs. Prep sits two stages upstream — it is a waiting room for TPS enrichment, not the import source.

The moment anyone replies, control comes back. Seven workflows answer texts, emails and calls — identifying the person, pulling their history across three channels, reading intent with an AI agent, deciding whether to auto-respond, and alerting the operator when a human should take over. Appointments get booked against real calendar availability.

A second, quieter layer watches all of it. Counters on every step, an error handler behind every workflow, a nightly synthesis at 4:30 that turns the day into the briefing read at 5:20 the next morning.

Three engines, not one

n8n runs the workflows. Apps Scripts run on the spreadsheets — and five of the six start workflows, so parts of the pipeline are script-driven rather than cron-driven. Playwright drives real browsers where a target has no API. No single tool can see all three, which is why the connections between them get their own section.

There are six acts across six databases. The first three acts build the list, the middle two work it, and the last one watches everything.

ActWhat it doesWorkflows
IBuyers tell us where to go5
IITurn a county into leads7
IIIHand leads to GHL2
IVAnswer everything that comes back7
VBook the appointment3
VIWatch the whole machine8

ACT I — Buyers tell us where to go

The founding decision of the business: do not guess which market to enter. Find people who are already closing deals, and go where they are buying. Every workflow in this act exists to turn "a person commented on a Facebook post" into "this county is worth entering."

CH 01

SB: BUYER INTEL

The capture. Where a stranger becomes data.

Design intent. One button press, one complete capture. The operator is scrolling a Facebook group and has seconds — so everything about this workflow optimises for not interrupting them: the extension grabs the rendered thread, this shapes it into four row types, and a toast comes back inside five seconds. It is the only place in the system where a human is the trigger and the clock.

Starts
POST /fb-capture — the Copy button in the Brave extension
Writes
public.posts · public.commenters · public.posters · public.commenter_verdicts
Reads
public.posts (for dedup)
Counters
captures_received · rows_written · capture_errors
Depends on
codex-postgres · OpenRouter · the Brave extension
Connects to
→ Ch 2 ZIP RANKING (via /zip-county) · → Ch 4 PROPOSER (via Commentor Verdicts)
First symptom
The Stream Deck toast reports a capture the sheet did not receive. Since Fix K the toast derives from actual write outcomes, so this now surfaces as ok:false naming the write that missed.
JSON
vCUMCGQPireL7kxE · buyer-intel/sb-buyer-intel.json

The operator finds an off-market post in a Facebook REI group and presses Copy. The extension scrapes the rendered post plus every comment, resolves the ZIP, and POSTs the whole thread here. An LLM parses it into four row types — the post itself, the people who commented, the person who posted, and a first-pass verdict on each commenter — then each lands on its own tab.

Features worth knowing

Upgrade surfaces.

  • A new capture type (story, reel): teach Normalize FB Clipboard the new capture_type, add a Sheet Append, wire it into Await All Writes.
  • Per-commenter enrichment (phone extraction, profile scoring): the seam is between Sheet - Commenters and Await All Writes.
  • Sheets → Postgres — DONE 2026-08-24. All four appends moved; the merge topology was untouched, as predicted. Sheet - Posters turned out to target a DELETED tab and had been discarding poster rows silently — it now writes public.posters.
  • Dedup on re-capture. Read Posts For Dedup → Mark Duplicate compares against existing rows. A re-grabbed post does not write a second Posts row, but its commenters still land: people who commented after the first capture are new leads, and person-level dedup happens downstream on profile_url.
  • The ZIP rule is 2a/2b/2c/2d, implemented in the extension, not here — the workflow trusts the ZIP it is given.
  • County routing never blocks capture. A ZIP in a county we already cover still gets captured; coverage only decides whether it is proposed.

Response topology. The four Sheet appends (Sheet - Posts, Sheet - Commenters, Sheet - Posters, Sheet - Commenter Verdicts) converge on an Await All Writes merge before Build Response runs, so the Stream Deck receives exactly one response per capture. Build Response reads row counts from each Sheet node's actual output (post-write), and ok is derived from whether the sheet(s) relevant to this capture_type landed rows — not hardcoded. Sheet failures still surface via settings.errorWorkflow into Obsidian, and also flip the Stream Deck toast to ok: false with a summary naming which write missed.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Buyer Intel (from any Cipher surface).
CH 02

SB: BUYER INTEL ZIP RANKING

Five endpoints on one canvas. The lookup service for everything buyer-side.

Design intent. Five unrelated questions share one workflow because they share one tab. Splitting them would mean five workflows all reading ZIP Ranking, five sets of credentials, and five places for the sheet id to drift. The cost of that choice is that a failure in one chain is invisible to the other four.

Starts
five webhooks: /zip-ranking /zip-status /county-buy-box /zip-county /operator-score
Writes
public.zip_ranking (upsert on zip)
Reads
public.county_buy_box · public.zip_buyers · public.posts · public.commenters
Counters
zips_ranked · ranking_lookups · ranking_errors
Depends on
Buyer Intel sheet · Census ZCTA data (via Zip County Resolver)
Connects to
← Ch 1 BUYER INTEL · ← Ch 3 ZIP INGEST · → Ch 4 PROPOSER · → PW 3 listsource-county-zips
First symptom
A post with a real location is skipped because no five-digit ZIP appears in it. The capture reports "no ZIP anywhere" and moves on — the lead is not logged as lost, it is simply never seen.
JSON
4GlPOFgi7PdHEjiH · buyer-intel/sb-buyer-intel-zip-ranking.json

Not one process — five independent chains that happen to share a workflow because they all serve the ZIP Ranking tab.

EndpointAnswersCalled by
/zip-rankingwrites ranked ZIPs for a countyListSource scraper
/zip-statusmarks a ZIP scraped/cleanedXLeads batch driver
/county-buy-boxwhat does this county's buy box look likelistsource-county-zips.cjs
/zip-countywhich county is this ZIP inCopy button and the Proposer
/operator-scorehow many posts/leads/ZIPs has this person brought inthe HUD panel

Features worth knowing

Known trade-offs.

  • /zip-county is deliberately shared upstream and downstream. The Copy button and the Proposer resolve through the same endpoint so a ZIP can never mean two different counties at two ends of the pipeline.
  • Recency lives here rather than on Drill Down, because tier is a property of the ZIP, not of a research view over deed rows.
  • Tier follows where activity is, not where the median falls. 42 buyers inside three months is hot even when a long tail drags the median past six.

Upgrade surfaces.

  • A sixth endpoint: add a webhook and its own chain; nothing is shared but the sheet.
  • Buy-box fit scoring: /county-buy-box is the natural home, and it already reads the box.

The buy box widens itself — the loop. A county's box is not built once. It bootstraps from one manually-found ZIP and then converges:

1 ZIP (manual copy-post discovery)  →  buy box: sound, but narrow
        ↓
ListSource ranks that county's ZIPs against it
        ↓
those ZIPs  →  XLeads  →  buyers + deeds for each
        ↓
box recomputed across 8, then 16 ZIPs  →  the county's real profile

This is why a single-ZIP box is served, not blocked. It only has to be good enough to point ListSource at the right ZIPs; the data that comes back replaces it. Blocking it would freeze the loop at step one — the county could never acquire the ZIPs that would widen it.

The gate therefore separates two questions. Not sound (too few transactions, or no real concentration) is blocked — that box would send ListSource hunting on noise. Sound but narrow (one ZIP, good numbers) is served with a note: more ZIPs widen what the box can claim, they do not make it more correct.

Granularity is county-level by choice. The box could be computed per ZIP, but that is too narrow to steer a county-wide search — it would rank ZIPs against a single street's profile. County-level averages are the working unit.

Pooling is by transaction, not by ZIP. cbbStats_ pools every buyer row in the county and derives one distribution, so a ZIP with 175 deeds moves the county box more than one with 40. That is a volume weighting, not a per-ZIP average, and it is usually the right call: it follows where the buying actually is.

Beds and baths get a width, not a measurement. Price, sqft, year and lot are stored as p25/median/p75 bands. Beds and baths are stored as a mode — "3bd (51%)" — and a range is reconstructed from it downstream by widening ±1 (Buy Box Resolver.js L191). ListSource itself takes ranges and the live endpoint serves them (BEDROOMS_NBR:[2,4]), so nothing is lost at the consumer.

What is lost is the shape. Because the widening starts from a single number, the range is always exactly ±1 whatever buyers actually did: a real 2–5 spread still emits [2,4] and drops the 5-bed buyers, while a genuinely tight market still emits [2,4] and reaches into homes nobody wants. Baths is additionally one-sided — it never widens below the mode.

Fixed 2026-08-20. cbbBand_ now grows a window outward from the mode until it holds a real, concentrated majority, and stores the band with its coverage: 2-4 bd + 90.3%. Quartiles were the obvious alternative and are wrong here — on live Harris data p25–p75 gives [3,4] and silently drops all 26 two-bedroom buyers, so do not “tidy” beds/baths to match the sqft/year/lot fields.

Two guards ride with it. A band is only trusted when it also beats what a window that wide would hold on a flat distribution (Harris: 4.27x; flat data scores ~1.0x and is marked weak) — because a coverage target on its own always succeeds, it just returns a uselessly wide range. And beds/baths were the two fields where this mattered most: the old rule could never reach below the mode, which excluded Harris's 18 one-bath buyers, 12% of that market.

Three implementations were reconciled at the same time — County Buy Box.js, Buy Box Resolver.js and the n8n Build Box node each widened a mode independently. All three now read the stored band.

How a location is resolved (built 2026-08-19). Four ZIP rules run first — 2a post body, 2b author reply, 2c commenter agreement, 2d unverified. When none of them find a five-digit ZIP, a fifth pass looks for a city:

  1. Known-name match against a Texas city list, longest names first so north richland hills beats richland hills.
  2. Free-text pattern — in <Title Case Words>[, TX] — which catches the long tail the list cannot hold.

A city candidate goes to Google Geocoding for verification, because the extension has no way to know whether Springtown is a real place.

A geocoded city is marked as a guess, not a fact. Every capture carries resolver_source: in_text_regex when a ZIP was stated, google_geocoding when a city was inferred. A city spans dozens of ZIPs across possibly two counties, so it is good enough to capture and propose the county — never good enough to pin a ZIP. The distinction travels with the row rather than being lost at the boundary.


Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Buyer Intel (from any Cipher surface).
CH 03

SB: BUYER INTEL ZIP INGEST

Deed rows arrive. Stale rows die first.

Design intent. Replace, never accumulate. A ZIP scraped twice should hold the second scrape, not both — so this deletes the previous rows before appending the new ones. Getting that order wrong is why the delete is the most carefully written node in the workflow.

Starts
POST /zip-buyers — from xleads-to-sheet.cjs, in 60-row chunks
Writes
public.zip_buyers · public.zip_buyer_roll
Reads
public.zip_buyers (to find stale rows)
Counters
buyer_rows_received · buyer_rows_written · ingest_errors
Depends on
PW 3 xleads-buyer-pull · codex-postgres
Connects to
← PW 3 xleads-buyer-pull · → Ch 2 ZIP RANKING (recency reads ZIP Buyers)
First symptom
A ZIP re-scrape lands but the roll does not update, so recency tiers keep reporting the previous pull. The rows are right and the derived view is stale.
JSON
gM4myni6M3Z1CJen · buyer-intel/sb-buyer-intel-zip-ingest.json

XLeads returns real deed records for a ZIP. This deletes the previous scrape for that ZIP, appends the new rows, then derives the per-buyer roll.

Known trade-offs.

  • Delete-then-append rather than upsert. XLeads rows carry no stable key, so there is nothing to match on. Replacing the ZIP wholesale is the only operation that is correct twice in a row.
  • 60-row chunks because a full ZIP payload hit 1.56 MB and nginx caps the body at 1 MB.
  • The delete is a batch code node, not a Sheets delete (Fix J, 2026-08-19). It removes the specific stale_row_numbers, sorted descending so row indexes do not shift underneath it. The earlier version deleted the span from first to last row, which would have taken other ZIPs' rows with it the first time a re-scrape hit an interleaved tab.

Upgrade surfaces.

  • A second deed source: the webhook contract is {zip, rows[]}; anything producing that shape can post here.
  • Sheets → Postgres — DONE 2026-08-24. Delete-then-append is now a transaction, and the ordering hazard is gone.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Buyer Intel (from any Cipher surface).
CH 04

SB: BUYER INTEL PROPOSER

The 8 AM ask: should we enter this county?

Design intent. A cheap, reversible gate in front of an expensive, irreversible one. Proposing a county costs a line at the morning brief; onboarding one costs a scrape, a buy box and weeks of outreach. So this workflow is allowed to be wrong, and is deliberately not allowed to act.

Starts
POST /buyer-intel-propose — from the Apps Script Proposer, ~8 AM
Writes
public.proposals
Reads
(none directly — the Apps Script does the reading)
Counters
proposals_received · proposals_written · proposal_errors
Depends on
codex-postgres · Buyer Intel Proposer.js (project-d, still reads the SHEET)
Connects to
← Ch 1 BUYER INTEL (via Commentor Verdicts) · ← Ch 2 ZIP RANKING (via /zip-county) · → the morning brief
First symptom
The morning brief has no county to discuss, and a missing proposal is indistinguishable from "nothing qualified today".
JSON
EgGihZv5JZNn6XsQ · buyer-intel/sb-buyer-intel-proposer.json

Clusters commenters by the counties their interested_zips resolve to, and when a county clears the threshold and is not already onboarded, writes one Proposals row. That row is what the operator reads at the morning brief.

Known trade-offs.

  • It runs on interest, not deeds — and that is the correct half of the pipeline. Deed rows only exist for counties already scraped, and scraping only happens after onboarding; using them here would be circular.
  • Shape Proposal throws on purpose for a missing county or a zero buyer count. It has no error track by design — a malformed proposal should fail loudly and write nothing.
  • Nothing auto-fires. PROPOSER_ARMED = false and the trigger is not installed. Proposing is a suggestion to a human, permanently.
  • Deed verification is not possible and never will be. XLeads can only be searched by county, ZIP or street — never by person. A workflow was once built to ask "what has this buyer purchased?" and it could never work; it has been deactivated. This is why the Proposer clusters on stated interest rather than confirmed purchase history.

Upgrade surfaces.

  • Scoring proposals (rank several counties): Shape Proposal builds the row and is where a score would attach.
  • Auto-arming: PROPOSER_ARMED in the Apps Script is the single switch, and it is off by design.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Buyer Intel (from any Cipher surface).
CH 05

SB: BUYER INTEL MESSENGER

After approval: draft the outreach.

Design intent. Write the first message while the evidence is still to hand. The workflow drafts and stops — status begins at draft and every transition after that is the operator's, because a first message to a real buyer is not something a schedule should send.

Built to the door, not through it. This workflow exists; the engagement track it opens onto does not. Engagement over text, email and calls is live elsewhere in this Atlas — engagement through social media is not built, and that is what buyer outreach actually requires. It is deliberately deferred to Phase 3, because a social engagement layer generates data every upstream system then has to process.

Starts
POST /buyer-intel-message — after a county is approved
Writes
public.outreach (the tab it targeted never existed — see The move to Postgres*)*
Reads
public.commenter_verdicts
Counters
outreach_requested · drafts_written · messenger_errors
Depends on
Buyer Intel sheet · OpenRouter
Connects to
← Ch 4 PROPOSER (a county must be approved first) · → the operator, by hand
First symptom
Not yet in service. When it does run, a failed draft shows as LLM spend on the funding tracker with no matching row on Outreach.
JSON
BiN5ABDZt9r0QFjs · buyer-intel/sb-buyer-intel-messenger.json

Reads the verdicts for an approved county, picks the buyers worth contacting, and has an LLM draft a first message per person.

Known trade-offs.

  • Drafts, never sends. There is no send node and that is deliberate; the operator reviews every first message.
  • The prose fallback keeps a malformed draft rather than discarding it. A human is reading these anyway, so partially-usable text beats a null.

Upgrade surfaces.

  • Send-side automation: Append Drafts is the terminus; a send would attach after it, gated on a status change.
  • Per-county voice: Prompt Builder is where county context enters.

Not to be confused with the Buyer Intel Extension. The Brave extension that actually performs the capture Chapter 1 receives — the Copy and Post buttons themselves — lives as its own component, buyer-intel-extension/, and is documented separately in The buyer's-side toolchain below rather than as a seventh chapter here. Chapters 1-5 are the n8n workflows the capture lands in; the extension is the client that produces the capture.


ACT II — Turn a county into leads

Once a county is approved and onboarded, the work inverts: instead of finding buyers, we find properties and the people who own them.

The act runs in a strict order, and each step depends on the one before it:

documents scraped -> documents read -> records validated -> owners enriched -> listing status checked -> bad contacts corrected

Chapters 7 and 8 are the front door. They were switched off between roughly March and 18 August 2026 while the pipeline was edited — deliberately, to stop leads reaching GoHighLevel and starting calls before the operator was ready — and came back on 2026-08-18.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Buyer Intel (from any Cipher surface).
CH 07

SB: PROCESSING SHEET WRITER

151 nodes, 52 crons. Where documents enter the business.

Design intent. The mouth of the business. Seven document types × five to seven counties, each on its own schedule, because county clerk portals publish on their own rhythms and hammering all 42 at once gets you blocked. The 52 separate cron triggers are not disorganisation — they are the schedule, expressed one source at a time.

Starts
52 cron triggers · a Drive trigger · POST /write-processing
Writes
public.lis_pendens · public.prep
Reads
(none — it is the source)
Counters
documents_written · intake_errors
Depends on
PW 1 scraper · /scrape · /process-tax · /skip-trace · codex-postgres
Connects to
→ Ch 8 DOC EXTRACTOR v2 · → Ch 14 SCH LEADS (via Prep)
First symptom
One county × doctype stops producing while the other 41 keep running. Nothing halts — the day just yields fewer documents from one source, and only the counter shows it.
JSON
BbZd6AHJZQBJ048aajPc3 · doc-extractor/sb-processing-sheet-writer.json

The true point zero of the lead side. Nothing precedes it: county clerk documents are scraped here, and every lead downstream traces back to a row this workflow wrote.

Seven document types across five to seven counties. Probate, Lis Pendens, Divorce, Mechanics Lien, Federal Tax Lien, State Tax Lien and Abstract of Judgment run in Williamson, Travis, Hays, Bell and Bastrop; Tax Delinquent additionally covers Burnet and Caldwell. That grid is why there are 52 separate cron triggers on one canvas — one per source, each on its own schedule.

Features worth knowing

  • The 42 dispatches are pokes, not transfers. Each tells the sourcing server to start a scrape; the server answers immediately and works in the background.
  • A failed poke is silent and costly. Unlike the Cipher notifications elsewhere in this document — which are correctly left untracked because they report on work already done — a failed dispatch means that county × doctype never runs that day and no leads come from it. Instrumented 2026-08-18: all 47 external nodes now route to Obsidian, and the formatter names the doctype and county so a row reads "LIS PENDENS / WILLIAMSON" rather than a node name.
  • Host-down is escalated. If the sourcing server is unreachable the failure is graded critical and says so explicitly — that is not one source missing, it is all 42 and intake is dark for the day.

Known trade-offs.

  • 42 dispatches instead of one loop. A loop would be tidier and would fail as one unit; separate nodes mean one dead county portal cannot take the other 41 down with it.
  • Poke, not transfer. Each dispatch asks the sourcing server to start a scrape and returns immediately. The work happens out of process, which is why a failure here means "never ran" rather than "ran and lost".

Upgrade surfaces.

  • A new county: add one cron per doctype pointing at the same /scrape route; nothing else changes.
  • A new doctype: same, one row of the grid.
  • Replacing the grid with a registry: county-registry.js on the sourcing server already knows which counties are active — the 52 crons could read it instead of being hard-coded.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Lead Pipeline / Dedup (from any Cipher surface).
CH 08

SB: DOC EXTRACTOR v2

Reads what chapter 7 collected.

Design intent. Turn a scanned court filing into a row with an address on it. Vision reads the document, the address is resolved against a real geocoder, and only then does it become something the rest of the pipeline can use. Thirteen counters exist because every stage of that can fail differently and the distinctions matter — a document skipped is not a document that failed.

Starts
POST /write · a UUID webhook · 4 crons
Writes
public.lis_pendens (unique on filename) · public.prep
Reads
public.lis_pendens
Counters
13 — docs_processed · docs_skipped · docs_needs_address · docs_complete · docs_error · vision_calls_made · …
Depends on
OpenRouter vision · /lookup-address · an Apps Script Web App · Processing · Codex_Core
Connects to
← Ch 7 PROCESSING SHEET WRITER · → Ch 9 DATA VER · → Ch 14 SCH LEADS
First symptom
docs_needs_address climbs while docs_complete flattens. The pipeline keeps running and every document is read, but nothing becomes a lead.
JSON
RO1yF9unzbnbeXqq · doc-extractor/sb-doc-extractor.json

Vision-reads the scraped documents, extracts the owner and property, resolves the address, and updates Prep. Between them, chapters 7 and 8 turn a county clerk filing into a lead record.

The most heavily counted workflow in the estate — 13 counters, including docs_processed, docs_skipped, docs_needs_address, vision_calls_made and prep_sheet_updates. It was built with observability from the start rather than retrofitted.

Live conflict: this workflow and chapter 7 both register a POST /write webhook, and both went active on 2026-08-18. n8n gives the path to only one of them — whichever registered last — and the other silently never receives a request. The two handlers feed near-identical Extract Items chains, which suggests v2 was built to supersede the writer's copy, but that has not been confirmed. Until it is, /write traffic is going somewhere unverified. (See gap #12.)

Known trade-offs.

  • Vision over OCR. Court filings are scans of varying quality with no consistent layout; a vision model reads context where OCR reads characters. It costs more per document and is the reason vision_calls_made is counted.
  • Address resolution is a separate step, not part of the read. A document can be perfectly extracted and still lack a usable address, and that state (docs_needs_address) is worth seeing rather than folding into failure.

Upgrade surfaces.

  • A new document type: the vision prompt and the field mapping are the two places that know what a Lis Pendens looks like.
  • Batch address resolution: /lookup-address is called per document; a bulk endpoint would attach at the same seam.

Standing gaps. (none — the Drive-cleanup gap was closed 2026-08-20; see below.)

Extracted documents are deleted from Drive (added 2026-08-20). For most of this workflow's life nothing was: Find PNG1 located the scan, Download PNG1 pulled it, and across all 53 nodes there was no delete anywhere, so Drive grew without bound.

Delete Drive PNG now runs after Write Results1 — the moment the data is durably in the Processing sheet, not merely the moment vision returned something. Deleting any earlier would risk destroying a document whose row never landed.

Two details worth knowing before touching it:

  • The Drive id had to be threaded through. Prepare Vision Request1 and Parse Extraction1 each rebuild the item from scratch, so the id from Find PNG1 was long gone by the success point. It now rides along explicitly. Re-looking-up by filename was the alternative and is worse — duplicate names would delete the wrong file, and a Drive delete is not reversible.
  • The guard is an allow-list. Only complete / success delete. The first version tested !status.includes('error'), which meant a blank status deleted the file — absence of evidence read as consent. skipped, needs_address, error, blank, and any status added later all keep their file: it is the only evidence of what went wrong and the only thing a retry could read.

Fire-and-forget (onError: continueRegularOutput) — the data is already written, so a failed delete costs one leftover file rather than a failed extraction. Counter: drive_files_deleted.

  • [gaps-query] Query current proposals for this section: /gaps DOC EXTRACTOR (from any Cipher surface).
CH 09

SB: DATA VER

82 nodes. The gate every scraped property must pass.

Design intent. The judgment step. Every scraped record arrives here and leaves through exactly one of four doors, and the four-way split is the whole point: a record that fails must land somewhere with a reason attached, never be dropped. Six switch nodes exist to make that routing explicit rather than implicit.

Starts
POST /sourcing · POST /fsbo90
Writes
Master · Discovery · Disqualified · Error
Reads
(none — it judges what it is handed)
Counters
7 — leads_in · leads_passed · leads_disqualified · leads_failed · …
Depends on
localhost:3042/zillow-check · Codex_Core
Connects to
← Ch 8 DOC EXTRACTOR · ← Prep Lead Handling.js (Apps Script) · → Ch 10 TPS · → Ch 14 SCH LEADS
First symptom
Everything routes to one destination. Because all four outcomes are "successful" writes, the workflow reports healthy while the mix is wrong — the counters diverging is the only tell.
JSON
I4nyoPgexPYlXjqUeHPTV · data-verification/sb-data-ver.json

The county scrapers post raw records here. This validates, deduplicates, checks Zillow for listing status, and sorts each record into one of four destinations — Master (good), Discovery (needs work), Disqualified (fails the box), Error (broken input).

The four-way split is the whole point: nothing is silently dropped. A record that fails always lands somewhere with a reason attached.

Known trade-offs.

  • Four destinations, never a delete. Disqualified and Error are kept rather than discarded, because "why did this not become a lead?" is a question worth being able to answer months later.
  • Zillow is checked at intake, not just in batch. A property already listed on the MLS is not off-market, and finding that out before enrichment saves the expensive step.

Upgrade surfaces.

  • A fifth outcome: the switch chain is the seam; a new branch needs its own sheet and its own counter.
  • Criteria changes: the buy-box comparison lives in the switch conditions, not in code.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps DATA VER (from any Cipher surface).
CH 10

SB: TPS Enrichment Queue

72 nodes. Turning an address into a person you can call.

Design intent. Turn an address into a person you can call. It is separated from DATA VER because enrichment is slow, costly and fights anti-bot defences — folding it into validation would make every record pay that price, including the ones about to be disqualified.

Starts
POST /tps
Writes
public.imports_logbook · public.master · public.discovery · public.disqualified · public.error
Reads
public.master
Counters
13 — leads_in · leads_enriched · tps_no_match · tps_phone_invalid · tps_error · tps_calls_made · …
Depends on
PW 5 tps-enrichment (own host) · /cbc-enrich · /zillow-check · Codex_Core
Connects to
← Ch 9 DATA VER · ← Ch 14 SCH LEADS (via Prep) · ← TPS Trigger.js (Apps Script menu) · → Ch 15 GHL IMP (via Master)
First symptom
Match rate falls while call volume holds. TPS answers 200 on a block page, so tps_calls_made stays healthy and leads_enriched quietly drops.
JSON
yOIn6_TkxSP4M7beilypD · tps-enrichment/sb-tps-enrichment.json

Skip-trace and contact enrichment. A property record goes in, phone numbers and owner identity come back. This is the workflow behind the Cloudflare + DataDome boss fight — the enrichment sources actively fight automation, which is why it runs through a dedicated tps.arcosai.io host and needs a real headful browser rather than plain HTTP.

It scores the call list (added 2026-08-20). Cleanup for Master1 already computed Motivation_Score and Score_Tier; it now also writes BuyBox_Fit, BuyBox_Score, BuyBox_Misses and Call_Tier.

The two scales are kept deliberately separate. Score_Tier judges a person — how likely are they to sell. BuyBox_Fit judges a house — can we move it to a buyer who exists. They are never summed: a D-tier lead in a perfect house is not the same as an A2 lead in a house nobody wants, and a sum would say it is. Call_Tier therefore groups by fit and orders by motivation — T1 (STRONG + A1-LP/A2) through T5 — so the operator always calls the most motivated person whose house is sellable.

Beds and baths carry the most weight (30 and 25 of 100) because they are the only dimensions with a measured coverage band behind them; sqft, year, lot and price are quartiles and inform without vetoing. A missing field is not a miss — scoring runs only over dimensions that could be judged — and a lead judged on too little returns UNKNOWN rather than a confident score off one field.

The county band is fetched once per run, before the row loop: that loop is items.map(...) and synchronous, so an await inside it would return promises instead of rows.

Imports LogBook is the audit trail: every enrichment attempt is recorded whether or not it produced a contact.

Known trade-offs.

  • Its own host and tunnel. tps.arcosai.io is isolated because this is the one target that actively fights automation; a block there must not take the main sourcing server with it.
  • Imports LogBook records attempts, not just successes. A no-match is evidence about the source rather than a non-event.
  • Started by a spreadsheet menu. TPS Trigger.js installs operator controls on Codex_Core — start, stop, backfill, retry — so the queue is driven by hand when it needs to be.

Upgrade surfaces.

  • A second enrichment vendor: the NO_MATCH branch is the seam.
  • Bulk mode: the workflow is per-lead; a batch endpoint would attach at /tps-enrich without changing the routing.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps TPS Enrichment (from any Cipher surface).
CH 11

SB: ZILLOW BATCH

Listing status in bulk.

Design intent. Master decays. A property that was off-market when it was scraped may be listed by the time anyone calls, and calling the seller of an actively listed house is the fastest way to waste a conversation. This keeps the list honest over time rather than only at intake.

Starts
POST /zillow-batch
Writes
public.master — both branches: the scored row and the ❌ ZILLOW_DQ: stamp
Reads
public.master
Counters
properties_checked · zillow_calls_made · master_writes · disqualified_writes
Depends on
sourcing.arcosai.io/zillow-check · codex-postgres · Codex_Core
Connects to
← Master Tab Filter.js (Apps Script) · → Ch 14 SCH LEADS
First symptom
Listed properties stay on Master. Nothing errors — the check simply stops returning listing status, and stale rows look identical to fresh ones.
JSON
_RXoQosQYEsMaDjftIP-n · zillow-batch/sb-zillow-batch.json

Re-checks properties against Zillow in batches. A property that has since listed on the MLS is no longer an off-market opportunity, so this keeps Master honest over time rather than only at intake.

Known trade-offs.

  • Batch rather than continuous. Re-checking every property on every read would be expensive and pointless; listing status changes on the order of weeks.
  • Started by a script, not a cron. Master Tab Filter.js decides when a re-check is worth running, which keeps the judgment next to the data.
CH 12

SB: WRONG NUMBER

The correction loop.

Design intent. Stop a bad number from recycling. Reporting a wrong number is not enough on its own — without a record, the same contact gets re-enriched, re-imported and dialled again. So this poisons the number in Postgres and marks DNC, then sends the record back to Discovery for a fresh attempt at reaching the right person.

Starts
POST /wrong-number · POST /wrong-number-resolved
Writes
public.discovery (append and update) · public.codexcore (poison + DNC)
Reads
(none — the caller supplies the contact)
Counters
wrong_numbers_reported · wn_resolutions · phones_poisoned · dnc_marked · discovery_writes · discovery_deletes
Depends on
codex-postgres
Connects to
← outreach, wherever a wrong number is discovered · → Ch 10 TPS (via Discovery re-enrichment)
First symptom
The same wrong number comes back around. If poisoning fails, the number is re-enriched and dialled again — the loop closes without anyone noticing it reopened.
JSON
PkGEKxNs4au6riMt · wrong-number/sb-wrong-number.json

When outreach reaches the wrong person, the number is flagged and the record goes back to Discovery for re-enrichment. Two endpoints because it is a round trip: one to report, one to close out once a corrected number is found. Without this, bad numbers would recycle through outreach forever.

Known trade-offs.

  • Poison rather than delete. The number is kept and marked, because deleting it means the next enrichment pass can rediscover and re-dial it.
  • Two endpoints, because it is a round trip. One reports the problem; the other closes it out once a corrected number is found. A single endpoint could not tell the two states apart.

Upgrade surfaces.

  • Automatic detection: today a human reports the wrong number. A call transcript classifier could post to the same endpoint.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Wrong Number (from any Cipher surface).
CH 13

SB_Email_Repair

The same loop, for email.

Design intent. The email counterpart to Wrong Number, deliberately simpler. A bad email is corrected in place on Master, because unlike a wrong phone number it does not invalidate the rest of the record — the person is still the right person, only one field was wrong.

Starts
POST /emailverify
Writes
public.master
Reads
public.master
Counters
email_repair_requests · emails_repaired
Depends on
codex-postgres
Connects to
← Ch 18 EMAIL IND (bounces) · → Ch 14 SCH LEADS
First symptom
Bounce rate holds steady while repairs report success. The correction lands on the row but the address is no better than the one it replaced.
JSON
U6S3Od4oVo4WLZW9HeSF5 · email-repair/sb-email-repair.json

Bounced or invalid addresses get re-verified and corrected in place. Writes to Master directly because an email correction does not invalidate the rest of the record — unlike a wrong phone number, which sends the whole record back.


Known trade-offs.

  • Repairs in place; does not route to Discovery. A wrong phone means the contact may be wrong entirely; a bounced email usually means a typo or a dead mailbox for the right person.

ACT III — Hand leads to GHL

Two workflows. Master is the boundary — everything before it is ours, everything after is GoHighLevel's until a reply comes back. Chapter 14 stages leads on Prep, TPS enrichment (Ch 12) splits them into Master / Disqualified / Error / Discovery, and chapter 15 pushes the qualified ones across from Master.

CH 14

SB: SCH LEADS copy

The dispatcher. Stages enriched leads onto Prep, where TPS picks them up.

Design intent. The last thing that is ours. Everything upstream decides whether a lead is worth working; this decides when — staging it on Prep so two very different consumers can take it: the 5:45 importer, and the operator with a phone. That dual audience is why the cron is parked rather than removed; the schedule exists but the judgment is human.

Starts
cron: 0 0 1 15 2 * — parked on a date that will not come round; in practice operator-driven
Writes
public.prep (append and update)
Reads
(none — it stages what enrichment produced)
Counters
skip_trace_dispatched · prep_sheet_updates · prep_sheet_appends · codex_core_appends · leads_scheduled
Depends on
/skip-trace · /dispatch-scheduled · codex-postgres
Connects to
← Ch 10 TPS Enrichment · → Ch 15 GHL IMP · → Ch 25 Call Prep Button
First symptom
Prep stops filling, or fills with stale rows. The operator notices at the phone, not in a log — nothing here errors when the staging simply does not run.
JSON
BrlWdAFQE08GtwGY · schedule-tag-leads/sb-sch-leads.json

Takes enriched, qualified leads and stages them on Prep, where TPS enrichment collects them. Prep is not the tab GHL imports from — that is Master, two stages downstream. The cron is parked on a date that will not come around soon; in practice this is operator-driven.

Prep is a waiting room, not the handoff boundary. (Corrected 2026-08-20 — the earlier text said Prep was what GHL imports at 5:45. It is not.) Leads land on Prep and wait for TPS enrichment (POST /tps, Ch 10). TPS is what splits them:

SCH LEADS → Prep → SB: TPS Enrichment Queue → Master
                                              → Disqualified
                                              → Error / Discovery

Only Master is read by GHL IMP at 5:45, and only rows with Status = ✅ and a Good_To_Process value. Prep sits two stages upstream of the handoff; the boundary is Master.

That distinction matters for anything scored or staged: a column written to Prep attaches to rows that have not been qualified yet, and is not carried across the split.

Lead quality is already settled by the time it arrives here, by three stacked filters upstream. This is a design invariant that is invisible from any single chapter, and removing any one filter would degrade the calls without any workflow reporting a fault:

  1. County — the buyer-first loop (Ch 1–5) only enters counties where buyers are demonstrably closing. The market is followed, not guessed.
  2. Property — buy-box filtering means desirable to a buyer who exists, rather than desirable in the abstract.
  3. Motivation — high-necessity niches only: tax delinquency, pre-foreclosure, probate. Necessity for change, not merely ownership.

By the time a row reaches Prep, two of the three positions are already correct. The call itself filters for the third — is this person motivated now — and that is a conversation, not a system responsibility.

call_tier and buybox_fit are built (2026-08-20) — on Master, not Prep, and computed by Ch 10 TPS Enrichment rather than here. See that chapter.


Standing gaps. (none)

  • [gaps-query] Query current proposals for this section: /gaps SCH LEADS (from any Cipher surface).

Known trade-offs.

  • The cron is parked, not deleted. 0 0 1 15 2 * fires on 15 February and is effectively manual. Keeping a disabled-by-date trigger preserves the wiring for the day scheduling becomes automatic, without pretending it already is.
  • Prep is written, never read. This workflow does not check what is already staged; deduplication is the importer's job, downstream.

Upgrade surfaces.

  • Automatic scheduling: the parked cron is the seam — the wiring is intact.

(call_tier and buybox_fit were listed here as a future upgrade. They shipped 2026-08-20 and live on Master, computed by Ch 10 TPS Enrichment.)

Standing gaps.

  • A crash is reported, a failing step is not. The workflow has settings.errorWorkflow set and five counters, but zero nodes carry an error track. A failed Prep write or a failed skip-trace dispatch is invisible; the counters simply do not increment, and nothing says why.
CH 15

SB: GHL IMP

5:45 AM. The last thing that happens before outreach begins.

Design intent. The handoff. Everything before this chapter is ours; everything after belongs to GoHighLevel until someone replies. It fires at 5:45 because that is two minutes after the morning brief locks the day's agreed import number — the only workflow in the estate whose schedule is set by a conversation rather than by a system.

Starts
cron: 45 5 * * 1-6 — Mon-Sat · POST /calling_all_leads
Writes
public.imports_logbook · public.master (update)
Reads
public.master (ordered by call_tier, then motivation_score) · public.imports_logbook · public.daily_intelligence
Counters
leads_imported · import_failed · imports_requested · logbook_writes · master_discovery_deletes · textible_imported · landline_imported
Depends on
GoHighLevel · codex-postgres · PW 9 Pearl (bulk CSV path)
Connects to
← Ch 10 TPS Enrichment (via Master) · ← Ch 26 OBSIDIAN (via the import cap) · → GoHighLevel, and out of this Atlas
First symptom
The import runs and imports nothing. imports_requested holds while leads_imported falls to zero — either the 4:30 agent never wrote today's row (the filter reports stale_cap: true with the date it looked for), or Approval or Deny denied the run for its own reasons.
JSON
PIDjg86GSpVAgAIj · go-high-level-import/sb-ghl-imp.json

Pushes qualified leads into GoHighLevel, where outreach takes over. Reactivated 2026-08-18 with chapters 7 and 8 — the three together reopen the intake valve.

Features worth knowing

  • It reads its own daily cap, and only today's. Daily Inteligence carries Import_Today_Textible_Target / Import_Today_Landline_Overflow, with Import_Today_Actual as the legacy single-number fallback, written by the Obsidian agent at 4:30 that morning. That is the Boss 3 Brain Stage 1 wiring: the brief decides how many leads enter, and the importer obeys. If there is no agreed number for today, it imports nothing — operator decision, 2026-08-20. A fallback would have to be capacity-based, and capacity needs the prior day's call outcomes, which are not yet recorded. The row is matched on Compose_Timestamp (full ISO date) against today in America/Chicago; Snapshot_Time carries no year and the tab is not in chronological order, so neither string matching nor row position is safe.
  • Textible and landline are counted separately — they route differently on the GHL side, and one combined figure would hide a split.
  • Imports LogBook is the audit trail, read as well as written, so a lead is not imported twice across runs.

Its position in the clock is deliberate and tight. The morning brief locks the agreed import number at 5:43; this fires at 5:45. Two minutes of buffer. If Obsidian's 4:30 synthesis or the 5:20 brief slips, this reads a stale or missing cap — which is why the 5 AM cluster order is load-bearing rather than incidental.

Known trade-offs.

  • The cap comes from the brief, not from capacity. Written by the 4:30 agent and confirmed by the operator at 5:20; the importer obeys it. A system that sized its own batches would be simpler and would remove the operator from the decision that most determines the day. It would also need yesterday's call outcomes to size anything honestly — which is precisely what is not yet being captured, and why "no agreed number" resolves to import nothing rather than to a guess.
  • Textible and landline counted separately. They route differently on the GHL side, and one combined figure would hide a split.
  • Imports LogBook is read as well as written, so a lead is not imported twice across runs.

Upgrade surfaces.

  • Import criteria: Filter N leads2 is where the cap is applied and where additional filtering would attach.
  • A second destination: the workflow is GHL-shaped throughout; pointing it elsewhere means replacing the import call, not the selection logic.

Standing gaps.

  • Two minutes of buffer, and no check that the brief happened. If Obsidian's 4:30 synthesis or the 5:20 brief slips, this reads a stale or missing cap and imports on it without noticing. Nothing verifies the number it obeys was agreed today.

ACT IV — Answer everything that comes back

GHL sends the outreach. The moment anyone replies, control returns to n8n. This act is the largest in the system — five of the seven biggest workflows live here — because a reply can arrive by text, email or phone, at any hour, and every one of them has to be understood, scored, logged and answered.

  • [gaps-query] Query current proposals for this section: /gaps GHL Integration (from any Cipher surface).
CH 16

SB: TXT IND

87 nodes. Inbound SMS. The reference implementation for the whole estate.

Design intent. Someone texted back — decide what happens next, in seconds, without waking the operator unless it matters. Five AI agents read the message for motivation and intent, history is pulled across three tabs, and the workflow either answers or escalates. Escalation is the product, not the auto-reply: the point is that a human is interrupted exactly when a human is needed.

Starts
POST /inbound-text
Writes
public.smstatus (upsert on phone_number) · public.nimbus · public.smsretry
Reads
public.nimbus · public.stage · public.smstatus · public.nimbus_archive — that table does not exist; see the register
Counters
7 — texts_inbound · autoresponded · inbound_errors · identity_stored · dnc_triggered · …
Depends on
GHL · Telegram · Cipher · OpenRouter · codex-postgres
Connects to
← GoHighLevel (inbound SMS) · → Ch 24 GET FREE SLOTS · → Cipher · → the operator, by Telegram
First symptom
Replies arrive and nothing answers. The webhook still returns 200 because it acknowledges before the chain runs, so GHL sees success while the conversation stalls.
JSON
uJMbaZO190HdWF9W · text-inbound/sb-txt-ind.json

Someone texts back. This identifies them, pulls their history, has an AI agent read the message for motivation and intent, decides whether to auto-respond, logs everything, and alerts the operator when a human should take over.

This is the workflow every other one was measured against in the 2026-08-18 audit. 36 of its 38 external nodes route their error output into a single Format Error -> Obsidian node, which fans out to both SB: OBSIDIAN and a counter. It is the most completely instrumented workflow in the system.

Two calls to sourcing.arcosai.io/cipher-inbound are deliberately left untracked. A Cipher notification is fire-and-forget — nothing downstream breaks if it does not land. That distinction (notification vs handoff) is the rule the whole audit was built on.

⚠️ Live typo: one node points at services.leadconnec**or**thq.com — letters transposed from leadconnectorhq. That host does not resolve. Worth fixing.

Known trade-offs.

  • Two Cipher calls are deliberately untracked. /cipher-inbound is fire-and-forget notification about work that already happened; nothing downstream breaks if it does not land. That distinction — notification versus handoff — is the rule the whole error-tracking standard was built on.
  • History is read from three tabs, not one. Someone who texts may have already emailed or called, and a reply that ignores that reads as a machine.

Upgrade surfaces.

  • A new intent class: the agent prompts and the switch that routes on their output are the two places that know the taxonomy.
  • Sheets → Postgres — DONE 2026-08-24. All 17 nodes moved, smstatus gained a unique key on phone_number, and the upsert now preserves fields the payload omits.

This line used to cite public.nimbus_archive as proof the pattern worked. That table never existed. The history lookup reading it carried continueErrorOutput and had failed silently on every inbound text since the node was copied in from TXT & Call. Repointed to public.nimbus, which has all seven columns the query asks for.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps TXT IND (from any Cipher surface).
CH 17

SB: TXT OBD

78 nodes. Outbound SMS, three attempts, with history.

Design intent. Three attempts, each written fresh. Six Analyst agents draft the message rather than filling a template, with separate paths for contacts who have conversation history and those who do not — because the second message to someone who ignored the first should not read like the first.

Starts
POST /outbound
Writes
public.nimbus
Reads
public.nimbus · public.stage
Counters
7 — texts_sent_day1 · texts_sent_day2 · texts_sent_day3 · send_errors · webhook_received · …
Depends on
GHL · Cipher · codex-postgres · OpenRouter
Connects to
← Ch 15 GHL IMP (a lead must be imported first) · → Ch 16 TXT IND (the reply comes back there) · → Ch 19 FOLLOWUP GENERATOR
First symptom
Attempt 2 or 3 stops firing while attempt 1 keeps working. The three counters diverging is the tell; nothing else distinguishes "nobody was due" from "the loop broke".
JSON
2yIFqcGzOXwTDFArp1KlQ · text-outbound/sb-txt-obd.json

The three-attempt outbound sequence. Each attempt runs an Analyst agent that writes the message, with separate paths for contacts who have conversation history and those who do not. Wait nodes hold the gaps between attempts.

Features worth knowing

  • SMS Error Router classifies each send: Success / Rate Limited / Failed. This is the closest thing in the estate to a purpose-built error track — it predates the audit.
  • It is only half-wired. The Failed fallback output is unconnected, and the Success output goes nowhere. Only Rate Limited reaches Emit Counter — send_errors.
  • The four GHL send nodes are still bare. They form a single loop with the Waits, so a thrown 429 kills the run — which means send_errors never fires and Section Health shows zero send errors on a day the sends broke. This is the highest-value outstanding fix in the system.

Known trade-offs.

  • Prep and send are split across two systems. This workflow composes and hands to GoHighLevel; the send, its retries and its cancellations live there. That is why per-node error tracks were deliberately not added — the failure is already caught on the far side.
  • SMS Error Router classifies rather than retries. Success, Rate Limited and Failed are separated so the counters can tell them apart.
  • Six Cipher writes untracked, for the same fire-and-forget reason as Ch 16.

Upgrade surfaces.

  • A fourth attempt: the loop is explicit, one branch per attempt, so a fourth is a copy rather than a rewrite.
  • Per-niche voice: the Analyst prompts are where tax-delinquency, probate and pre-foreclosure language would diverge.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps TXT OBD (from any Cipher surface).
CH 18

SB: EMAIL IND

91 nodes. The largest workflow in the estate. Inbound email.

Design intent. The largest workflow in the estate, and the email twin of Ch 16. It reads across all three history tabs because someone who emails may have already texted or called — and it can bridge to SMS, switching channel when the reply suggests a text will land better than another email.

Starts
POST /couriernet
Writes
public.couriernet
Reads
public.nimbus · public.stage · public.smstatus
Counters
8 — emails_received · emails_replied · sms_bridge_triggered · couriernet_logged · telegram_notified · …
Depends on
GHL · Telegram · OpenRouter · codex-postgres
Connects to
← GoHighLevel (inbound email) · → Ch 13 SB_Email_Repair (bounces) · → Ch 17 TXT OBD (the SMS bridge)
First symptom
Email replies stop being answered while texts keep flowing. Both arrive through GHL, so a failure here looks like a quiet day rather than a broken channel.
JSON
AdlQfLEaBeat61RL · email-and-scheduler/email-and-scheduler.json

The email twin of TXT IND. Reads across all three history tabs, because someone who emails may have already texted or called and the response has to know that.

Biggest instrumentation gap in the estate: 39 external nodes, 14 tracked, 25 bare. It has a crash handler, so a total failure is reported, but 25 individual failure points are silent.

Known trade-offs.

  • 91 nodes rather than several workflows. Every branch shares the same history lookup and the same identity resolution; splitting would duplicate both.
  • Channel switching is one-way. Email can escalate to SMS; SMS does not fall back to email, because a text that goes unanswered means something different from an email that does.
CH 19

SB: FOLLOWUP GENERATOR

The nurture engine.

Design intent. Write the nurture message GoHighLevel is about to send. Its Track A/B nodes call out for a body, this composes one that is stage-aware and does not repeat anything the contact has already received, and GHL sends it. Both channels live here, split by a Channel Switch — email one arm, SMS the other.

Starts
POST /followup-generator
Writes
public.couriernet · public.nimbus · public.intake_governor
Reads
public.stage · public.couriernet · public.nimbus
Counters
(none — by design; see Known trade-offs)
Depends on
GHL (two webhook triggers) · OpenRouter · codex-postgres
Connects to
← GoHighLevel (Track A/B nodes) · ← Ch 17 TXT OBD (after the initial burst)
First symptom
Follow-ups go out empty or repeat an earlier message. The GHL node fires either way, so the sequence continues and the contact receives something worse than silence.
JSON
8jvmZU086B3bOwBL · followup-generator/sb-followup-generator.json

Generates the LLM email and SMS bodies for GHL's Track A/B follow-up nodes. Reads Stage plus both history tabs so a follow-up is stage-aware and never repeats a message the contact has already received.

SB: TXT OBD is deliberately untouched by this — the initial three-attempt burst stays where it is. This handles what comes after.

⚠️ All 8 of its external nodes carry continueRegularOutput — every GHL send and every log write swallows its own errors. A follow-up can silently fail to send and the workflow reports clean. Second-highest-value outstanding fix.

Known trade-offs.

  • The initial three-attempt burst stays in Ch 17. This handles what comes after; keeping them separate means a change to nurture cannot break first contact.
  • Reads three tabs before writing one. Stage for where the contact is, CourierNet and Nimbus for what they have already been sent.
  • It emits no counters, and that is correct. Both HTTP nodes hand to GoHighLevel, which owns the send and reports its own outcome. Counting a handoff as though it were a delivery would report volume this workflow cannot observe — the same reason Ch 34 counts presses rather than rows written.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps FOLLOWUP GENERATOR (from any Cipher surface).
CH 20

SB: CALL EXT - GHL

Phone calls become structured data.

Design intent. Read a call twice, through two different lenses. An MCTP extractor pulls what the call means for the deal; a Coach extractor pulls what it means for the operator's technique. They are kept deliberately separate so sales extraction and coaching feedback cannot contaminate each other — an agent grading your delivery should not also be deciding the lead's motivation score.

Starts
POST /ghl-call-ext · manual
Writes
Stage (30 coach columns) · Intake Governor
Reads
Stage
Counters
posts to /intake-event — volume and errors, via Emit to Intake Governor
Depends on
GHL (call transcripts) · OpenRouter · Codex_Pulse
Connects to
← GoHighLevel (a completed call) · → Ch 22 BOXING COACH PRIMER (via Stage)
First symptom
Stage stops gaining coach columns after calls. The call still happened and the transcript still exists — only the extraction is missing, and nothing downstream demands it.
JSON
TgVhnTzreWsdJMjM · call-ext-ghl/sb-call-ext-ghl.json

Runs two extractors in parallel over a call transcript — an MCTP lens and a Coach lens — deliberately kept separate so sales extraction and coaching feedback never contaminate each other. Writes 30 coach columns to Stage.

⚠️ Four swallowing nodes, including Emit Error to Intake Governor — the error reporter suppresses its own failures.

Known trade-offs.

  • Two extractors rather than one prompt. One agent doing both would blur the lenses, and the coaching output would start justifying the sales read.
  • 30 coach columns on Stage rather than a separate tab, so a lead and its coaching history stay on one row.
  • Counters go over HTTP, not through an Emit Counter node. Emit to Intake Governor and Emit Error to Intake Governor both POST to /intake-event directly; the Append to IG Sheet (Direct) node beside them is a fallback, not a substitute. Any audit scanning for nodes named Emit Counter — * will report this workflow as uninstrumented. It is not.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps GHL Integration (from any Cipher surface).
CH 21

SB: INBOUND CALL NOTIFY

Someone is calling right now.

Design intent. Ten nodes with one job: tell the operator who is calling before they pick up. Everything about it is shaped by that deadline — it writes Stage after alerting, not before, because a row can wait and a ringing phone cannot.

Starts
POST /inbound-call-notify
Writes
public.stage
Reads
(none — the payload carries the caller)
Counters
inbound_calls · caller_id_alerts · stage_writes
Depends on
Telegram · codex-postgres
Connects to
← GoHighLevel (an incoming call) · → the operator, immediately
First symptom
The phone rings with no context attached. The operator answers blind — a failure measured in seconds, and the only one in the estate where being late is the same as being broken.
JSON
SNnh9hUCc_kOXS3JCk7HC · inbound-call-notify/sb-inbound-call-notify.json

Real-time Telegram alert on an inbound call. The deleteMessage call is the interesting part: the alert is removed once it is no longer relevant, so the operator's phone does not accumulate dead notifications.

Known trade-offs.

  • It deletes its own alerts. A deleteMessage call removes the notification once it is no longer relevant, so the operator's phone does not accumulate a history of calls that have already been answered.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps GHL Integration (from any Cipher surface).
CH 22

SB: BOXING COACH PRIMER

The mid-block coaching button.

Design intent. Coaching between rounds, not after the fight. Pressed mid-block, it synthesises the calls made so far into something the operator can act on in the next call rather than review at the end of the day — which is why it reads Stage live rather than waiting for an end-of-day rollup.

Starts
POST /coach-primer — Stream Deck or /coach in Telegram · manual
Writes
public.boxingcoachqueue
Reads
public.stage
Counters
coach_presses · primers_queued · coach_errors
Depends on
OpenRouter · codex-postgres · the Cipher bridge (polls the queue)
Connects to
← Ch 20 CALL EXT (via the coach columns on Stage) · → Cipher, by voice or text
First symptom
The button is pressed and Cipher says nothing. Silence is indistinguishable from having nothing to report, so a failure looks like a quiet block.
JSON
QgwcoGKkLODod7FP · boxing-coach/sb-boxing-coach-primer.json

Synthesises the day's calls so far into a coaching primer and queues it. The Cipher bridge polls BoxingCoachQueue for Status='pending' and delivers it by voice or text.

Queue for Bridge is terminal, which made its old swallow flag pure loss: the Stage read, the LLM synthesis and the billed work were all complete, and a failed append meant Cipher simply never spoke. Silence was indistinguishable from having nothing to say. Fixed in the audit.


Known trade-offs.

  • A queue rather than a direct send. The workflow writes a row and stops; the Cipher bridge polls for Status='pending'. That keeps voice delivery out of n8n and lets Cipher choose the channel.
  • Two entry points, both counted. The Stream Deck button and the manual trigger both increment coach_presses, because a silently failed test press misleads exactly as much as a real one.

ACT V — Book the appointment

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Curfew / Security (from any Cipher surface).
CH 23

SB: Scheduling Tools

Five endpoints. The calendar API.

Design intent. Calendar access as a service, so nothing else has to hold calendar credentials. Five webhook chains, five independent paths, no shared state between them — a workflow rather than five workflows because the credential is the thing being shared, not the logic.

Starts
/scheduling/create-event get-events update-event delete-event free-slots
Writes
Google Calendar (create · update · delete)
Reads
Google Calendar (getAll) · GoHighLevel (free slots)
Counters
(none — see Standing gaps)
Depends on
Google Calendar · GoHighLevel
Connects to
← Ch 25 Call Prep Button · ← Ch 24 GET FREE SLOTS · ← Cipher · ← Ch 16 TXT IND
First symptom
Booking fails while the seller is still on the phone — the one failure in this chain that cannot go unnoticed.
JSON
YpvsiQ0ANXvJVH9o · scheduling-tools/sb-scheduling-tools.json

Full CRUD over the calendar, exposed as webhooks so any other workflow — or Cipher — can book, move or cancel without owning calendar credentials.

The end of the call chain. SCH LEADS (Ch 14) supplies the lead, the Call Prep Button (Ch 25) opens the conversation, and this is where it is meant to land. Being the terminus makes it the only step whose failure is immediately visible: the operator is still on the phone.

Known trade-offs.

  • Two sources of availability. Four chains talk to Google Calendar directly; free-slots uses the native GoHighLevel node instead, because GHL owns the booking rules — buffers, working hours, round-robin — that a raw calendar read cannot see. The calendar says when you are free; GHL says when you are bookable.
  • Five chains, no shared nodes. Each webhook has its own normaliser and its own responder. Duplicated by design: a change to update cannot break delete.

Upgrade surfaces.

  • A sixth operation (reschedule, availability windows): add a webhook, normaliser, calendar node and responder — the pattern is uniform.
  • Migrating off Google Calendar: the four calendar nodes are the only coupling; the normalisers and responders are provider-agnostic.
  • The HUD is becoming the operator-facing surface for this. A calendar glance sits on the map's toolkit roster and the Call Prep Button already opens the HUD on a lead — see The HUD (after Ch 36) for what has actually shipped versus what is still a named intent.

Standing gaps.

  • Nothing counts bookings. Appointments made is the outcome the entire pipeline exists to produce, and no counter measures it. Section Health can show leads in, texts sent and calls extracted, but not the one number that says whether any of it worked.

Known trade-offs (continued)

  • No error track, deliberately. A failed booking surfaces instantly because the operator is on the phone with the seller when it happens. That is the strongest detection in the estate — a human watching in real time — and an error row written after the fact would add nothing they do not already know.
CH 24

SB: GET FREE SLOTS

What times can we actually offer?

Design intent. Answer one question — when am I actually free? — from real freeBusy data rather than an assumption. It exists separately from Ch 23 because reading availability and mutating the calendar are different risks: this one can be called freely by anything that needs to offer a time, and cannot change anything.

Starts
called by another workflow · POST /cipher
Writes
(none — it answers, it does not record)
Reads
Google Calendar freeBusy
Counters
slot_lookups · slot_lookup_errors
Depends on
Google Calendar API
Connects to
← Ch 16 TXT IND · ← Cipher · → Ch 23 Scheduling Tools (the booking that follows)
First symptom
Offered times are already taken. The lookup answers with stale or empty availability, and the mistake surfaces as a double-booking rather than an error.
JSON
2m-4oVwYHFae2PdOXZum7 · get-free-slots/sb-get-free-slots.json

Queries real free/busy data. Callable both as a sub-workflow and directly by Cipher, which is why it has two entry points.

Known trade-offs.

  • Two entry points. A sub-workflow call for other n8n workflows, and /cipher for Cipher directly. Same logic, two callers with different transports.
  • Read-only by construction. It cannot book, so it can be called speculatively — during a conversation, in a draft, wherever a time might be offered.

A third surface is arriving. The HUD's map toolkit design names a day/week calendar glance as a sibling to the draw-to-filter tools — reading from whatever this chapter's availability check resolves to. Not built as of this writing; see The HUD (after Ch 36).

CH 25

Due Diligence - Call Prep Button

Brief me before I dial.

Starts
POST /due-diligence
Writes
No writes — returns response to Stream Deck client only.
Reads
Codex_Core Postgres via Lookup Codex Core1 node (lead-specific prep data).
Counters
calls_prepped (emitted on every button press).
Depends on
Prep (Codex_Core) · the comping tool
Connects to
← Ch 14 SCH LEADS copy · → Ch 23 Scheduling Tools
First symptom
The brief comes back thin mid-call and the operator improvises. Nothing logs it — the failure is absorbed by the person on the phone.
JSON
Ke1Y8j-QICLexxV3SkaVi · due-diligence-call-prep/due-diligence-call-prep.json

Design intent. Four nodes, pressed live. It assembles what is known about a lead in the seconds before the operator speaks, and launches the comping tool so condition and price can be worked through with the seller on the same call.

The middle of the call chain. SCH LEADS (Ch 14) supplies the lead, this button opens the conversation, and the call is meant to end in Scheduling Tools (Ch 23) with an appointment booked. The sequence is operator-mediated — none of the three call each other, which is why an automated scan reads all three as isolated.

Known trade-offs. Thin — 4 nodes, no error path. Postgres timeout returns empty brief silently; operator improvises live. No retry.

Upgrade surfaces. Add error output → SB: OBSIDIAN (matches every other chapter). Richer prep sources: last-call notes, LLC unmasker, buyer-side context.


ACT VI — Watch the whole machine

Everything above produces work. This act produces knowledge about the work. It is the layer that was largely blind before the 2026-08-18 audit.

CH 26

SB: OBSIDIAN

47 nodes. The error handler, the counter sink, and the nightly synthesis.

Design intent. The place failures go to be understood rather than merely recorded. Two entry points do different jobs — Error Trigger catches a workflow that died, Execute Workflow Trigger catches a step that failed while the workflow carried on — and at 4:30 it reads everything it has collected and turns the day into the briefing the operator gets at 5:20.

Starts
cron: 30 4 * * 1-6 · POST /Count · called by another workflow · error trigger
Writes
public.obsidian_error_handler · public.nimbus_error · public.couriernet_error · public.sourcing_error · public.intake_governor · public.daily_intelligence
Reads
public.heartbeats · public.proposals · public.funding · public.section_health · the four error tables
Counters
6
Depends on
sourcing.arcosai.io/brain/daily-intel-facts · OpenRouter · codex-postgres
Connects to
← every instrumented workflow (Entry A and Entry B) · → Ch 15 GHL IMP (the import cap) · → the morning brief
First symptom
The 5:20 brief arrives thin or stubbed. Everything upstream keeps running and every error track keeps firing — they just drain into a synthesis nobody reads.
JSON
X-X2WiHq83IAkbGroxTuA · obsidian-air-handler/sb-obsidian.json

Two entry points, and the distinction is the spine of the whole audit:

  • Entry A — Error Trigger (Entry A - Crashes). n8n fires this when a workflow dies. Wired by setting settings.errorWorkflow on the other workflow. Catches total failure.
  • Entry B — Execute Workflow Trigger (Entry B - Item Failures). Called explicitly when a step fails but the workflow keeps running. This is what every Format Error -> Obsidian node in this document feeds.

A crash handler alone means a failing step is invisible. A track alone means a crash is invisible. Both is the standard.

At 4:30 AM it reads everything — heartbeats, errors, funding, proposals, section health — and synthesises the day into the briefing the operator gets at 5:20.

Deliberately not instrumented. It cannot report its own failures to itself without looping, and the operator's judgment is that its failure probability is low enough not to warrant a separate alert path. If Obsidian dies, every other workflow's error track drains into nothing — accepted, and recorded here so it is a known trade rather than an oversight.

Known trade-offs.

  • It carries no error tracks of its own. Reporting its failures to itself would loop, so it is deliberately uninstrumented.
  • All 14 Sheets nodes carry retryOnFail — 3 tries, 2s backoff — because socket drops on Sheets writes were its most common failure and a retry solves what a report only describes.
  • Four error tabs, not one. Sourcing, Nimbus, CourierNet and the master tab are separated so a domain-specific retry can walk one without reading the others.

Upgrade surfaces.

  • A fifth domain: add a tab and a route in the formatter's provider mapping.
  • A dead-man's switch in Apps Script. See Standing gaps — the seam already exists.

Error hygiene — the standing rule.

A row in obsidian_error_handler is a claim that something is still wrong. If that stops being true and nobody says so, the count becomes noise and the operator learns to ignore it — which costs more than having no count at all.

When you fix something, close its row in the same pass as the fix. Not later, not "when we do a sweep". The fix and the close are one piece of work.

UPDATE public.obsidian_error_handler
   SET status = 'CLOSED',
       resolution_notes = '<what was actually wrong> — fixed <date>: <what changed>'
 WHERE _id IN (...);

Rules that make the count trustworthy:

  • Close on fix, not on age. Old and quiet is not the same as fixed. SB: PREP LEAD HANDLING ran healthy every 15 minutes for days while failing at 05:30 daily — anything that closed it on age would have buried a live bug.
  • Always write resolution_notes. A bare status flip erases why. The note is what makes a wrong close findable and reversible.
  • Close every row of an incident, not just the one you read. One task-runner stall on 2026-08-31 produced seven rows across two workflows in the same minute. Fixing the cause fixes all seven.
  • Never close what you have not fixed. A row you cannot explain stays open. Tidying the number is the one thing this rule exists to prevent.
  • Operator-error rows still get closed — with a note saying so. [] and [errorMessage, ...] from the capture button were the guard working correctly; they are not defects, and they are not open problems either.

What closes rows automatically. SB: ERROR REAPER (TXScGI5XKskc6zQJ, 06:30 Mon-Sat) closes a row only when its exact signature — workflow + node + digit-normalised message — has been quiet for 3 days and the workflow has emitted at least 3 sensor events since. Throughput alone is never enough: it proves the workflow is alive, not that the cause was addressed. Everything it closes carries its evidence in resolution_notes.

The reaper is a backstop for rows that quietly stopped mattering. It is not a substitute for closing what you fixed — it cannot know a fix happened, only that a failure stopped recurring.

The HUD needs nothing. It re-reads this table filtered on status every five minutes. The database is the single source of truth; write CLOSED and the count falls on its own.

How its death is detected. An Obsidian watchdog lives in Section Health Aggregation Engine.js — deliberately outside n8n, because a watcher inside the failure domain it watches is no watcher at all. It checks this workflow's last execution against a 2-hour lookback and alerts Cipher directly by Telegram, with a cooldown so it does not spam while n8n is fully down. An unreachable n8n is the true dead-man case and is alerted too.

Its secrets live in Script Properties (SH_N8N_API_KEY, SH_CIPHER_TOKEN, SH_CIPHER_CHAT); until all three are set it no-ops silently.


Known trade-offs.

  • Only two counters, and that is honest. n8n never learns how many rows landed, because the write happens on another host. A fabricated "written" counter would report success this workflow cannot observe.
  • A fire-and-forget call that still gets an error track. Unlike the Cipher notifications elsewhere, the POST here is the work request — if it fails, the scrape never starts and the operator sees an inert button.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps OBSIDIAN (from any Cipher surface).
CH 27

SB: Intake Event Receiver

The front door for every counter in the estate.

Design intent. One insert, deliberately fail-open. Every counter in the estate posts here, and telemetry must never block the workflow reporting to it — so it answers {ok:true} whether or not the row landed. That choice is why the response shape matters: {"ok":true,"id":"9274"} is a real insert, {"ok":true} alone is a dropped one.

Assert Row Landed closes the loop that choice used to leave open. Build Response always knew whether a row came back — it tests id != null — but only used it to decorate the response, so a dropped insert looked exactly like a good one and the July outage hid for four weeks. The assert now reads that same fact and throws, routing to Report Drop -> Obsidian. The caller still gets {ok:true}; the difference is that a drop now leaves a trace.

Starts
POST /intake-event
Writes
observability.intake_events
Reads
(none)
Counters
(none — by design; a counter here would POST to itself)
Depends on
codex-postgres
Connects to
← every counter in the estate · → Ch 28 Intake Aggregation
First symptom
Counters return {ok:true} with no id. The response shape is the only tell — a real insert echoes a row id and a timestamp.
JSON
x9pUbtA6gk5p5EXI · intake-governor/sb-intake-event-receiver.json

Known trade-offs.

  • No error counter, on purpose. A counter here would POST to /intake-event, which is itself; a Postgres outage would loop until n8n fell over. It reports to Obsidian alone, which writes to Sheets — a separate dependency, reachable precisely when Postgres is what broke.
  • Fail-open rather than fail-loud. The alternative would make a telemetry outage into a business outage.
  • Two detectors, deliberately separate. Insert Intake Event's error track catches a thrown failure; Assert Row Landed catches the other case — the insert returning without error and without a row, which is what a fail-open wrapper around a dead connection produces. They report through different nodes so neither can trigger the other: the shared error formatter feeds Build Response (that is how the caller still gets {ok:true} when the insert throws), so routing the assert into it would close a cycle whose entry condition is the very outage it exists to catch.
  • Respond OK builds its body explicitly rather than echoing $json. The drop flag has to live inside json to survive the node boundary — n8n normalises Code-node items to {json, pairedItem} and discards sibling keys — so the response is a declared shape, not whatever happens to be on the item.
  • A malformed payload is still inserted, on purpose. Validate + Defaults stamps workflow/sensor as (missing) rather than rejecting, so a broken emitter leaves greppable evidence instead of vanishing. The row is the only trace that something is misconfigured; refusing it would delete the evidence. The caller is now told — {"ok":true,"skipped":true,"reason":"..."} — which it previously was not: the validator's _ok flag does not survive the Postgres node (RETURNING replaces the item), so Build Response reads the verdict from $("Validate + Defaults") directly.

Measured 20 Aug 2026. 9,422 rows since 19 May; 4 junk rows, 0.042%, every one a test probe. Section Health totals are not meaningfully polluted. Retention prunes at two years, so today's test rows age out in 2028 — not worth a bulk delete against a live table.

Standing gaps. (none)

  • [gaps-query] Query current proposals for this section: /gaps Air Handler (from any Cipher surface).
CH 28

SB: Intake Aggregation

Counters become a dashboard.

Design intent. Turn a raw event stream into something a person can read. Four times a day it collapses intake_events into per-sensor daily counts and writes them to two independent stores — Postgres for querying, the sheet for reading — neither feeding the other.

Starts
cron: 0 0,9,14 * * * · cron: 55 19 * * * — America/Chicago
Writes
observability.intake_slots_daily · public.intake_governor
Reads
observability.intake_events
Counters
slots_upserted · ig_rows_written · aggregation_errors
Depends on
codex-postgres
Connects to
← Ch 27 Intake Event Receiver · → Ch 26 OBSIDIAN (via Intake Governor)
First symptom
Section Health shows yesterday. Nobody waits on this workflow, so a failure produces no visible symptom — the dashboard keeps its previous values and looks plausible.
JSON
ugU0TZ2XebuodbUs · intake-governor/sb-intake-aggregation.json

Known trade-offs.

  • Two sinks, counted separately. One can fail while the other succeeds, leaving the stores disagreeing with nothing to reconcile them. Separate counters are what expose that split.
  • No "received" counter. The trigger is a cron; a fire that produced nothing already shows as the absence of the two write counters.
  • The :55 timing is intentional. It feeds the four-times-daily funding numbers into the evening block. Funding Tracker moved to 19:50 rather than this one moving, so the two no longer contend for codex-postgres.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Air Handler (from any Cipher surface).
CH 29

SB: Intake Retention

Two nodes. The quietest workflow in the estate.

Design intent. One bounded DELETE, keeping intake_events from growing without limit — a slow problem that only becomes visible long after it starts.

Starts
cron: 0 3 * * *
Writes
(deletes from observability.intake_events)
Reads
observability.intake_events
Counters
prune_runs (a heartbeat) · prune_errors
Depends on
codex-postgres
Connects to
← Ch 27 (the table it prunes)
First symptom
None, for about two more years. Its correct result today is deleting zero rows, so a total failure and a perfect run are indistinguishable until the table is old enough for the prune to matter.
JSON
xTY6yrFT3rOftedG · intake-governor/sb-intake-retention.json

Known trade-offs.

  • prune_runs is a heartbeat, not a row count. Counting rows_deleted would read 0 forever on healthy runs and be indistinguishable from the workflow being switched off. Proving it ran is the only useful signal.
  • Graded warning, not critical. A failed prune loses nothing; the rows simply remain. Grading it critical would teach the operator to discount critical elsewhere.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Air Handler (from any Cipher surface).
CH 30

SB: HEARTBEATS

Is everything still alive?

Design intent. Ask the one question nothing else can: are the browser sessions still logged in? The probes do no scrape work — each opens a persisted profile and checks the target does not redirect to a login page, or calls a credential-gated API. An expired cookie is invisible to every other form of monitoring and is the most common way the Playwright fleet dies.

Starts
cron: 0 5 * * 1-6 — Mon-Sat
Writes
public.heartbeats (upsert on script_name)
Reads
(none — the probes report)
Counters
heartbeats_recorded · heartbeat_errors
Depends on
PW 4 heartbeats · sourcing.arcosai.io/heartbeats/fire-all · codex-postgres
Connects to
← PW 4 (the 8 probes) · → Ch 26 OBSIDIAN (read at 4:30)
First symptom
Every script shows a healthy timestamp. When this fails the tab keeps yesterday's values, so the dashboard for detecting deadness looks fine precisely when it has stopped working.
JSON
UQyuPu9YA2dZzhzj · intake-governor/sb-heartbeats.json

Known trade-offs.

  • Deliberately lightweight probes. They confirm the environment, not the work. A probe that actually scraped would be slower, costlier, and would itself need monitoring.
  • A 10-minute timeout for 8 serial probes. Exceeding it means the sourcing server is wedged rather than slow, which is why that failure is graded critical without hedging.

Upgrade surfaces.

  • A ninth probe on Gmail IMAP. xleads-auth.js is a single choke point for nine scripts and nothing watches it; the verification-code path failing would take out the whole fleet at once with no signal.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Playwright Fleet (from any Cipher surface).
CH 31

SB: EOD Rollup Poke

23:55. The last thing that happens each day.

Design intent. Poke the Apps Script that closes the books, so the 4:30 synthesis has a finished day to read. Three nodes, and the interesting one is the third: a response check, because this is the only external call in the estate that lies about failure.

Starts
cron: 55 23 * * *
Writes
(none directly — the Apps Script writes)
Reads
(none)
Counters
eod_rollups_confirmed · eod_rollup_errors
Depends on
the Section Health Apps Script Web App (/exec)
Connects to
→ the Apps Script Web App · → Ch 26 OBSIDIAN (reads the rollup at 4:30)
First symptom
The morning brief reads yesterday's rollup. Apps Script answers 200 even when it refuses the action, so a failure looks identical to success unless the response body is read.
JSON
hBI5pZKxPPXluEWA · intake-governor/sb-eod-rollup-poke.json

Known trade-offs.

  • A response check, not just an error track. Apps Script Web Apps answer HTTP 200 and put the failure in the body. An error track alone would never fire, so Check Rollup Response reads the body and throws. This is the only place in the estate using the pattern — see the silent-vendor-response class.
  • A timeout does not prove failure. The node waits 30s; Apps Script runs up to six minutes. The error message says so, and warns to verify before re-running to avoid double-counting.
  • retry_eligible: FALSE. Nothing re-pokes this. Tomorrow's fire rolls up tomorrow, so a missed night stays missed unless run by hand.

Upgrade surfaces.

  • The stale-deployment trap: /exec serves a frozen snapshot, so editing the script without redeploying leaves this hitting old code and answering 200. The response check is what turns that into a visible event.
CH 32

SB: Funding Tracker

Do we have money to operate tomorrow?

Design intent. Four pit stops a day across five vendors, with edge-triggered alerts when a balance crosses a threshold — because the failure mode being guarded against is not overspending, it is discovering at 5 AM that a vendor stopped working overnight.

Starts
four crons — 05:05 · 09:30 · 14:15 · 19:50
Writes
public.funding · public.funding_state (upsert on key)
Reads
public.funding_state · public.intake_governor
Counters
posts to /intake-event via Emit Funding Counts and Emit Spend Counters
Depends on
OpenRouter · Twilio · Telegram · PW 7 openai-balance-hybrid · PW 8 CaptainVerify · codex-postgres
Connects to
← five vendor APIs · → the operator, by Telegram
First symptom
A vendor reads zero and stays there. Nothing errors — a wrong field returns a valid number, and the balance simply stops moving.
JSON
qm4afhjKzBHollxZ · Funding Tracker/SB_ Funding Tracker.json

Known trade-offs.

  • All ten spend categories are snapshots, not increments. Aggregate with latest_per_day, never sum. This is the workflow where reading the wrong field — per-key limit-usage instead of account total_usage — made OpenRouter spend read zero for weeks.
  • Counters emit over HTTP through Emit Funding Counts and Emit Spend Counters rather than nodes named Emit Counter. An audit scanning by node name will report this workflow as uninstrumented. It is not.
  • Edge-triggered alerts, not level alerts. A balance crossing a threshold fires once; a balance sitting below it does not fire every pit stop.
  • The evening pit stop is 19:50, not 19:55 (moved 2026-08-19). Both this and Ch 28 wrote to codex-postgres at :55, and a funding write landing mid-aggregation made that pit stop's counts vanish.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Funding Tracker (from any Cipher surface).
CH 33

SB: JV SIGNALS — Skool surface

Community chatter becomes leads and knowledge.

Design intent. Judge community chatter before it becomes noise. An LLM gatekeeper decides what is worth keeping and routes knowledge one way, people the other — the two are counted separately because a tip and a lead are different products consumed by different processes.

Starts
POST /jv-signals
Writes
public.jv_tips · public.jv_leads
Reads
(none — the payload carries the comments)
Counters
signals_received · jv_tips_written · jv_leads_written · jv_signal_errors
Depends on
OpenRouter · Community Comments sheet · PW 6 skool-discovery
Connects to
← Ch 34 COMMUNITY COMMENTS (via the sourcing server)
First symptom
signals_received stops incrementing while pull_button_pressed keeps rising. Neither workflow errors — the gap between the two counters is the only evidence the pipe broke.
JSON
pqrk5qbGIl5186o5 · community-comments/sb-jv-signals-skool-surface.json

Known trade-offs.

  • The gatekeeper judges the whole batch. If it fails, every signal from that pull is lost at once rather than one row — which is why its failure is graded critical.
  • Tips and leads counted separately. One combined figure would hide a routing failure that sends every signal to the wrong side.

Standing gaps.

  • Route by tab declares a fallback output that is unwired. An item matching neither branch disappears. Nothing falls through today because the upstream code only ever emits JV Tips or JV Leads, but the shape is the same one that left TXT OBD's Failed output unconnected.
CH 34

SB: COMMUNITY COMMENTS — pull button

The other half of Chapter 33.

Design intent. Two nodes: deliver a button press. The scrape, the gatekeeper and the sheet write all happen on the Mac; n8n's only job is to ask. The server answers {"status":"started"} immediately, which is why the 15-second timeout is generous rather than tight — it waits on the acknowledgment, not the work.

Starts
POST /9ecdd88e-… — the pull button
Writes
(none — the sourcing server writes)
Reads
(none)
Counters
pull_button_pressed · pull_errors
Depends on
sourcing.arcosai.io/community-comments · PW 6 skool-discovery
Connects to
→ the sourcing server → Ch 33 JV SIGNALS
First symptom
The button is pressed and no rows appear. The POST is acknowledged in under a second regardless, so the button feels like it worked.
JSON
J8HvNUT37g0EKFhs · community-comments/sb-community-comments-pull-button.json

Known trade-offs.

  • Only two counters, and that is honest. n8n never learns how many rows landed, because the write happens on another host. A fabricated "written" counter would report success this workflow cannot observe.
  • A fire-and-forget call that still gets an error track. Unlike the Cipher notifications elsewhere, the POST here is the work request — if it fails, the scrape never starts and the operator sees an inert button.

CH 35

SB: SECTION HEALTH

The first live writer section_health has ever had.

Design intent. Replaces two Apps Script functions — runHourlyRollup and runEndOfDayRollup — which were byte-for-byte the same computation on different clocks. One workflow, two crons. It reads 30 days of intake events, computes 44 metrics, and writes the results back.

It matters more than its size suggests. Cipher reads Section Health for the morning brief. Before this, that read went through an Apps Script web app serving a sheet with no writer — a snapshot frozen in place while the business moved. The brief was quoting history and calling it state.

Starts
Cron hourly · cron 23:50 America/Chicago
Writes
section_health — computed columns only
Reads
intake_governor (30-day window) · section_health (definitions)
Counters
(none — it is the measurement layer, not a thing measured)
Depends on
Intake Governor events being emitted by the 18 instrumented workflows
Connects to
→ Cipher's morning brief · → The HUD (below Ch 36) — its gauges band and executions drawer read this table live
First symptom
Metrics all read 0 while events are clearly arriving. Means a category name in the definitions no longer matches what the emitters send.
JSON
uxTQqxtmJIQ5sMEP

Two rules carried over exactly, because getting either backwards is silent.

  • spend_* categories use the literal delta, and 0 is a real value. Everything else falls back to 1 when the delta is blank, because a counter event with no delta still means "one occurrence". Backwards, this inflated Today to $1 on genuinely zero-spend days.
  • latest_per_day keeps only the newest event per calendar day, then sums across days. Spend is a running total per pit stop, so summing every row multiplies by the number of pit stops.

Known trade-offs.

  • The 44 metric definitions were extracted from the Apps Script, not retyped. A mistyped category does not error — it silently returns 0, and the metric reads as dead rather than broken.
  • The write names only computed columns. The Apps Script read the whole block, mutated it in memory and wrote it back with setValues, so an operator edit landing mid-run was clobbered. Section, Metric, Channel, Direction and Industry baseline are now untouchable by construction.
  • Verified against an independent calculation, not "it ran". A hand-written SQL query put OpenRouter's 7-day spend at 4.6412; the workflow returned 4.641227150000001 — the hardest metric in the set, and the one where latest_per_day had to collapse multiple events per day correctly.

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Air Handler (from any Cipher surface).
CH 36

SB: CIPHER SQL

The gateway that lets Cipher see the database.

Design intent. Postgres lives behind SSH; the Mac running Cipher cannot dial it. n8n sits inside that boundary and holds the CodexLibrary credential. This is one authenticated endpoint that runs one SQL statement and returns rows.

The alternative was handing Cipher an n8n admin API key — which is how the migration tooling reaches Postgres, by building a throwaway workflow per query. That would give her full control of every workflow in the estate in order to run a SELECT.

Starts
POST /webhook/cipher-sql — header auth, X-Cipher-Key
Writes
anything the statement writes
Reads
anything the statement reads
Counters
(none)
Depends on
the CodexLibrary Postgres credential · the header-auth credential
Connects to
← telegram_bridge.py
First symptom
403 Authorization data is wrong! — the token in the bridge and the token in the n8n credential have diverged.
JSON
KGvoOeC7NJXWV5fX

Access is arbitrary SQL. The guard is about accidents, not permission.

The operator chose full SQL deliberately so repair work can happen in conversation. The realistic failure is therefore not a stolen token — it is an LLM composing UPDATE section_health SET tier = 'low' and forgetting the WHERE, which in Postgres hits every row and cannot be undone.

blocked shapeoverride
UPDATE / DELETE with no WHERE"force": true
more than one statement per request"force": true
DROP / TRUNCATE"force": true

A denial happens before the database is touched and says why. The guard strips strings and comments before checking, so a WHERE inside a quoted literal cannot satisfy it and -- WHERE id=1 cannot disguise a bare DELETE.

Known trade-offs.

  • It is the only authenticated webhook in the estate. Every other one is open on a public domain, which is survivable for a purpose-built endpoint and not for one that runs arbitrary SQL.
  • No bind parameters. $1 has nothing to bind against; callers inline literals with escaped quotes. The limitation is real and permanent.
  • Rows cap at 500 and the reply says truncated. A silent cap would have Cipher reason confidently about a partial answer. An uncapped SELECT * on lis_pendens — 3,971 rows — would blow her context and cost real money.
  • It shipped with two defects that only firing it revealed. The webhook had responseMode: lastNode while also wiring a Respond to Webhook node, which n8n rejects outright — every request returned HTTP 500 before the guard ran. And a failed query returned ok: true with the error buried in rows[0], because n8n delivers a continued-past error as an ordinary item. The gateway built to give Cipher true state was itself reporting success on failure. Both fixed; callers may now trust ok.

The HUD — hud.arcosai.io

The cockpit. Where the operator's day starts and where every other chapter in this Atlas becomes something a human can act on without opening n8n.

What it is. A live web app served from /var/www/hud/index.html on the VPS — deliberately not a Claude-artifact page, because the CSP an artifact runs under would block every map tile it draws. Leaflet over OpenStreetMap, a gauges band, a nav band, an app-rail for switching surfaces, and — as of 2026-09-09 — a full property pipeline layered onto the map itself. It reads codex-postgres through SB: CIPHER SQL (Ch 36), the same authenticated gateway Cipher uses, so the HUD and Cipher can never see two different versions of the estate.

This is the "HUD when it lands" the Section Health chapter (Ch 35) once pointed at as a future promise. It has landed. It is not a dashboard — a dashboard is read; a cockpit is acted on, and every gauge here is meant to change what the operator does next rather than sit as a number.

Serves from
/var/www/hud/index.html on jade-vps, nginx-fronted
Reads
codex-postgres via SB: CIPHER SQL (Ch 36) · Census/USGS/USFWS/NWS/Overpass/Redfin public data
Writes
public.focus_area · public.map_note · public.map_view · public.comp_pulls · public.curated_events · PSA fields via PSA Field Update (n8n 1rLrfDy9u2zPwN4V)
Depends on
SB: CIPHER SQL · Leaflet/OSM · the public geo APIs it composes
Connects to
← Ch 35 SECTION HEALTH (the metrics it was built to stop hiding) · ← Ch 26 OBSIDIAN (obsidian_error_handler, read live in the nav-band drawer) · → Ch 23/24 Scheduling (the calendar surface) · → Ch 25 Call Prep Button (opens the HUD on a lead)
First symptom
The map goes quiet or a gauge reads zero while Postgres is plainly moving — check SB: CIPHER SQL is active before assuming the HUD itself is broken; a Docker networking drop on 2026-09-23 took the connection out from under it with no code change on either side.
Design source
Designs/system/2026-08-21-hud-spec.md, plus a working session log at Workflows/hud/conversation.md — read that file for the day-by-day build, this chapter for the shape.

Design intent. The operator's day resolves to three questions, and the spec's own framing has held since the first sketch: am I set up, where's the meat, what am I doing right now. A top gauges band answers the first, a full-width map the second, an execution row the third. Everything on the page earns its pixels by changing behaviour when it moves — a number nobody acts on does not get a home here.

What actually shipped, in the order it landed:

  • 2026-09-01/02 — nav band and map overlays. The land-nav line (where you are in the day, how long until the next window, when Cipher speaks next) drapes as a drawer over the gauges rather than pushing them — the page's own rule is one screen, no scroll, and a drawer that displaced the map would break it. Four map overlays survived a fifth's-worth of trial: soil (ISRIC SoilGrids), topography (USGS 3DEP hillshade), wetlands (USFWS), live weather (RainViewer + NWS click-readouts). Traffic and flood were tried and dropped — every live-traffic source wanted a paid key, and flood was not a decision the operator was making from this screen.
  • 2026-09-02 — the search band, county selection, the call-prep bridge. Due Diligence - Call Prep Button (Ch 25) now opens the HUD on a lead as well as returning its own brief, so the map and the call-prep flow share one entry point.
  • 2026-09-03 — the regional intel panel, live sky, pipeline canisters. Five "Monsters Inc. tube" canisters — Buyers, ZIPs, Prep, Master, and one more — each fire their own /api/sql read so the pipeline's shape is visible without a query. First real test of the map plotting anything at all: as of that session public.master held zero rows, so the canisters were true but empty — an honest zero, not a broken one.
  • 2026-09-03 (evening) — the Condition panel, replacing JotForm as the in-house record of a property's physical state.
  • 2026-09-04 — the map toolkit. Draw-to-focus-area, notes-on-map, layer-stack saved views. Storing the polygon, not the lead ids inside it, was the deliberate choice — a polygon is a standing intent that re-evaluates as new leads land; a frozen id list goes stale the moment a lead is worked. Selecting highlights rather than filtering, so drawing a focus area never hides context the operator might still need.
  • 2026-09-05 — the executions drawer. Pulls the whole bottom row up into two panels: channel engagement (sent/delivered/opened/failed per call, text, email) and lead lifecycle (how long is each stage holding leads). Panel B shipped live against real intake_governor counts; Panel A shipped deliberately scaffolded but starved — every row renders against its real query, which currently returns zero, and each empty row names the missing wire ("nimbus has no rows — SMS status is not being written back from GHL") rather than hiding the panel. A zero that means "no sensor attached" must never look like a zero that means "nothing happened."
  • 2026-09-06/07 — the pin, the altimeter, the county-coverage overlay, the Governor card. The altitude-zoom pattern that later became the county intelligence telescope (below) took shape here.
  • 2026-09-08 — the reverse-walk drill ladder and Redfin market data. ZIP → City → County → State, each tier's card flips to show the grain that tier can actually support — see the Redfin ingest sub-pipeline below.
  • 2026-09-09/10/11 — PSA shipped end to end, the Parcel tier, Conversational Tools. The purchase-and-sale-agreement flow went from an unwired seat in the execution row to a working push via PSA Field Update, gated on resolving a phone number (not an address) to a GHL contact id — the permanent identity key, decided explicitly because the HUD knows an address and GHL needs a contact. The Parcel tier renders building footprints, lot boundaries and a coverage/efficiency ratio against comps, drawing at city zoom and tighter only — a Texas county holds 100k-400k parcels and cannot be drawn whole. Conversational Tools — power/water/cell/civic infrastructure pins plus hand-curated seasonal events — auto-arm at parcel zoom.
  • 2026-09-23 — the nav-band/search-bar stacking conflict fixed, and an error dismiss/restore feature shipped directly against obsidian_error_handler (Ch 26): a hud_dismissed_at column lets the operator hide a seen-but-not-yet- fixed error from view without touching SB: ERROR REAPER's proof-based close — "I've seen this" and "this is actually fixed" are different claims, and the dismiss auto-expires at 72 hours to mirror the Reaper's own window rather than hiding a live problem indefinitely.

The county intelligence telescope — designed, mostly not built. The operator's stated end state is one continuous zoom from the whole country down to a parcel — nation and region trade flows, county population and economics, ZIP buyer interest, block-level land intel, parcel-level comps — each altitude answering a different question and handing the next one down. As of 2026-09-05 three of seven altitudes existed (county coverage, ZIP buyer heat, block-level soil/topo/wetland); nation and region economics turned out to be the cheapest remaining piece, not the most expensive, once the research confirmed exports/imports/population are single keyed Census/trade-data calls covering every state or county at once, needing no per-place work at all. Projects and parcels are the opposite shape — acquired one county at a time through the ICM Factory, because there is no national permit feed — and a descending-floor rule governs how a project surfaces by zoom: a $500M+ project shows from nation zoom down, and every level adds smaller projects rather than replacing the view, so a data centre beside a neighbourhood never disappears just because the map zoomed in on it. See Designs/system/hud-county-intelligence.md for the full source list and the worked Austin/San Antonio permit-portal examples.

The Redfin ingest sub-pipeline

Workflows/hud/redfin-ingest/ (ingest.cjs, pg.cjs) — a small, standalone Node script, not an n8n workflow. It pulls Redfin's public Data Center bulk files (median sale price, days-on-market, at ZIP/city/county grain) and writes them into public.redfin_market for the HUD's intel-card rows. Deliberately Texas-only: the ZIP file alone is nationwide back to 2012, and filtering to TX at ingestion keeps the table sized to how the tool is actually used rather than loading every US region on spec. State-level rows are excluded by design — state renders through the separate Census trade-data path above, and never reads this table.

It reaches Postgres the same way every other tool outside n8n's boundary does — cloning a credentialed workflow and firing it by webhook (pg.cjs is a direct copy of the same pattern as pg-exec.cjs and walker-pg.js, kept duplicated deliberately so a fix in one place can be checked against the others rather than silently diverging).

Known trade-offs.

  • Not built as an n8n workflow. It is a script run by hand, matching the GHL-inventory scraper's own "first-time pull, not a drift monitor" shape — Redfin's bulk files update on their own schedule and this is not wired to a cron.
  • A city match is the least reliable of the three grains. ZIP is a clean 5-digit parse; county strips the County suffix and matches on name+state; city matches Redfin's own city name against the state code, and Census place names, annexations and incorporated-vs-not naming are all real mismatch sources. A miss renders as "no market data available" on the frontend — the same fail-soft pattern an ACS miss already uses — never an error.

Standing gaps.

  • The map toolkit's own dependents (lead-card popups, tier-coloured pins, buyer-heat gap detection, focus-area-feeds-morning-brief) stay blocked on the same upstream fact recorded elsewhere in this Atlas: public.master is empty because zip_dispatch has never fired. The HUD is not broken; there is nothing yet for it to plot.
  • PSA's identity resolution (phone → GHL contact id) shipped, but the broader question of what "brought together" means across the HUD, the CRM mimic and Atlas itself — named 2026-09-23 as the four-piece consolidation — has no scoping work done yet.
  • Two leaked secrets surfaced during the 2026-09-23 n8n Postgres migration (below) — the codex-postgres admin password and the SB: CIPHER SQL gateway key, the HUD's own data credential — are still unrotated as of this writing.

The data layer

Migrating, as of 2026-08-24. Six Google spreadsheets are being replaced by one Postgres database — codex-postgres, 46 tables in public. The coupling between workflows lives in this layer rather than in n8n, and it did not change shape in the move: nothing in the n8n UI draws an edge between two workflows that share a tab, and nothing draws one between two that share a table either.

Where the migration stands. COMPLETE — 123 of 123 active Sheets nodes are on Postgres. Zero remain in any active workflow; every read, append, update and upsert runs against codex-postgres.

Sheets is not switched off. The five Apps Script projects still read it directly through SpreadsheetApp, so it stays live until those are rebuilt as n8n workflows. That is phase 3, and it has not started.

A chapter's Writes and Reads lines say which store it uses: a backticked public.table is Postgres, a plain tab name is still Sheets.

Until every node moves, both stores are live and neither is complete. The Apps Scripts still read Sheets directly, so a table is not the whole truth for any database whose scripts have not been rebuilt.

DatabaseTabsActive workflowsApps Script projectPostgres
Codex_PulseStage · Nimbus · CourierNet · SMStatus · SMSRetry · BoxingCoachQueue7project-e — 1 file12 tables, all empty
Codex_CoreMaster · Prep · Discovery · Disqualified · Error · Imports LogBook6project-b — 1 file (was 7)7 tables · 8 rows
System IntelligenceIntake Governor · Proposals · Heartbeats · Funding · Funding State · Section Health · Daily Inteligence · 4 error tabs7project-a + project-c — 1 file each (was 5 + 1)12 tables · 3,399 rows
Buyer IntelZIP Ranking · ZIP Buyers · ZIP Buyer Roll · County Buy Box · Posts · Commentors · Posters · Commenter Verdicts · Outreach5project-d — 1 file (was 13)13 tables · ~892 rows
Community CommentsJV Tips · JV Leads1project-f — empty stub2 tables · 20 rows
ProcessingLis Pendens and other filings2none1 table · 3,976 rows

What each database is for

Codex_Pulse — the conversation layer. Every message in either direction. Stage is the lead's current state, Nimbus the SMS history, CourierNet the email history. The busiest database in the estate by traffic, and the one whose coupling is most dangerous: six workflows write Stage.

Codex_Core — the property layer. Raw scraped records become qualified leads here. Prep is the waiting room — SCH LEADS stages leads there and TPS enrichment collects them. TPS then splits every lead four ways: Master is the qualified list and the tab GHL imports from, Discovery the re-work queue, Disqualified and Error the two rejection paths.

The order matters and is easy to get backwards: Prep → TPS → Master → GHL. A column written to Prep attaches to rows that have not been qualified yet and does not survive the split.

System Intelligence — the observability layer. Nothing operational lives here; it is entirely about the health of everything else. Counters land on Intake Governor, failures on the four error tabs, and Obsidian's nightly synthesis on Daily Inteligence.

Buyer Intel — the discovery layer. The buyer-first loop, from a Facebook comment through to a county proposal.

Community Comments — the JV surface. Two tabs, one workflow, no script layer by design — the read side runs on Playwright, and the specced reply side was never built.

Processing — lead intake. Where county clerk documents become leads. Written by chapter 7, read and enriched by chapter 8. Reopened 2026-08-18 after a deliberate pause.

The move to Postgres

Why. Sheets is the structural bottleneck, and it is measurable rather than theoretical: SB: OBSIDIAN logged 1,037 failed executions in two weeks, almost all rate limits, with three-try retries already in place. Types are the other half — a score stored as text sorts 10 before 9, and every comparison needs a cast.

What a swap actually is. A Google Sheets node becomes a Postgres node running an explicit query. The node keeps its name, position, error routing and every connection, so the workflow graph is unchanged. Four shapes cover the whole estate:

SheetsPostgres
appendINSERT
appendOrUpdateINSERT … ON CONFLICT (key) DO UPDATE
updateUPDATE … WHERE key = $n
readSELECT … ORDER BY _id

The tab-to-table mapping is one-to-one and deliberately unrestructured. Four near-identical error tabs stayed four tables; Day 1/2/3 stayed three. Consolidating during a move means discovering afterwards how many nodes talked to the old shape.

What the migration exposed

Moving stores does not create these — it surfaces them, because Postgres refuses what Sheets accepted silently. Six, in the order found:

  • Two tabs had been DELETED while workflows kept writing to them. SB: BUYER INTEL MESSENGER wrote LLM-drafted outreach to a tab named Outreach; SB: BUYER INTEL wrote Facebook poster records to gid 1388084543. Neither exists. Sheets accepts an append to a missing tab without erroring, so both discarded every row for months while the nodes reported success and their counters incremented. Now public.outreach and public.posters.
  • Master!A1 was blank where all three sibling tabs say Status. Apps Script writes that column by POSITION so nothing broke — and four active SB: GHL IMP reads filter on it. Restored; every tab across all five databases then swept for the same defect, and Master was the only one.
  • The buy box was computed and had nowhere to land. Buyer intelligence produced a measured per-county box and computeBuyBoxFit scored every lead against it, but Call_Tier and the three BuyBox_* columns existed in neither the Master header nor the table. Added at header positions 5-8.
  • The importer never used the scores. Its reads ended ORDER BY _id — insertion order — and the slicer took the first N on the strength of a comment: "Master is pre-sorted by Motivation_Score so we just slice." True while sortMasterByScore reordered the Sheets tab nightly, false once the read moved to a table nothing sorts. Now ordered by tier, then motivation.
  • The error handler could not log errors. Its Timestamp is yearless (Aug 24, 12:05 PM) and the array parameter path had no date normalisation — so the workflow whose entire job is catching silent failures was failing silently, down its own error path.
  • public.nimbus_archive has never existed. SB: TXT IND reads it for SMS conversation history on every inbound text, and the node carries continueErrorOutput. Pre-existing, not caused by this migration, and still open.

The habit that found them: assume a thing is broken until a query says otherwise. Not one announced itself. Each came from a check that refused to guess, a dry-run reporting UNMAPPED, or a sweep of references nobody had touched.

Where it stands, updated 2026-09-24

n8n side, application data: complete since 2026-08-24. 123 nodes across 46 workflows repointed to codex-postgres. Nothing has regressed this since — the work in this section is now about what sits on top of that migration, not the migration itself.

Apps Script side: still 6 of 24 trigger handlers retired. Unchanged since August — no Apps Script rebuild work landed in September. The four-piece consolidation named 2026-09-23 (Atlas, the CRM mimic, the HUD, "tenant two") is the more recent claim on the operator's attention, and Phase 3's own sequencing (below) already put the script rebuild ahead of the vector store — it has simply not been picked back up yet.

retired
runHourlyRollup + runEndOfDayRollup → Ch 35 · runOEHDedup → Ch 29 · sortMasterByScore → importer ORDER BY · checkDisqualifiedLeads → Ch 24 · nightlyCleanup → Ch 18
parked
runRetryOrchestrator — has never retried anything; all 501 rows hardcode retry_eligible: FALSE, and both config vocabularies have zero overlap with the real error codes
remaining
1 fold · 4 standalone · 8 edit-driven (held for the edit-surface decision) · 5 deletes

A second, unrelated Postgres migration happened underneath this one. On 2026-09-23 n8n's own internal executions/workflow database — not codex-postgres, and not the subject of the rest of this section — was moved off SQLite onto a new sibling database (n8n_db) inside the same Postgres container. The old database.sqlite had grown to 31.6GB and could no longer serialise concurrent writes, which was the root cause behind six of nine open criticals in obsidian_error_handler that same week. All 86 workflows and 32 credentials were exported and re-imported into the new instance; 57 of 86 are reactivated as of this writing, the remaining 29 deliberately inactive (superseded duplicates, one-time setup scripts, non-production, or standing operator decisions — see Workflows/milestones/conversation.md, 2026-09-23, for the full triage). Old SQLite execution history has not yet been copied into n8n_db; the operator wants it kept as reference rather than discarded. Two secrets surfaced and were flagged live during this work — the codex-postgres admin password and the SB: CIPHER SQL gateway key — and remain unrotated. This event is why the reactivation count in Phase 4 — live progress (46 workflows) and the count here (86, GHL-inclusive addressing) can look like they disagree: they are describing the same estate through two different exports taken weeks apart, not two different states of the world.

The lead-flow tables are still empty, and that is still correct in shape — but the reason has shifted. master, discovery, disqualified and the Codex_Pulse tabs read 0 rows in August because the pipeline carries no standing inventory. They read 0 rows now because public.zip_dispatch — the buyer-intel dispatch table upstream of all of it — has never fired a batch. The HUD's own map-toolkit and executions-drawer designs (see The HUD, after Ch 36) both independently hit this same fact in September and recorded it as the launch blocker rather than a modelling question. SB: BUYER INTEL PROPOSER COMPUTE — the workflow that decides which counties get dispatched — was itself found inactive for an unknown stretch during the September reactivation triage, which means at least part of this emptiness was an availability gap, not only a volume one.

Which means a cutover test today still exercises schema more than data, narrower than it looks. Every node writing into an empty table passes every structural check — right table, right columns, right conflict key — and proves nothing about whether a lead survives the journey. Real verification needs a live lead pushed through, and that is gated on the dispatch fix above, not on anything in this section.

What has to happen before cutover

The Apps Scripts still read Sheets directly — none of them go through n8n. Until they are rebuilt (see The script layer), a table is not the whole truth for its database. That is the next phase, not this one, and it has not started.

Row position is shipped to n8n as data. _sheet_row, _sheet_gid and row_index travel in webhook payloads and downstream workflows write back by row number. Every one of those changes when the address becomes a key.

The shape of the rebuild — not six workflows

The six projects are a packaging accident, not a design. They exist because Apps Script binds a project to a spreadsheet, so everything touching Codex_Core ended up in one file. Rebuilding them one-for-one would carry that accident into n8n.

The real unit is the trigger. Across the five live projects there are 27 trigger installations:

projecttriggersweb app
a — System Intelligence8yes
b — Pipeline12yes
c — Doc Extractor2yes
d — Buyer Intel3no
e — Codex_Pulse2no

Each is an independent job with its own schedule. Some deserve their own workflow; most belong folded into a workflow that already does adjacent work — the heartbeat writers into SB: HEARTBEATS, the error sweeps into SB: OBSIDIAN, the sort into the importer's ORDER BY, which has already happened.

A large share of the 27 disappears rather than moves. Trigger installers, LockService blocks, property-store queues, header-index mapping, quota sleeps, row-position addressing — all of it exists because Apps Script works that way. n8n has a scheduler and Postgres has keys and transactions.

The measured split is in Workflows/milestones/phase-3-apps-script-to-n8n.md, which classifies every function as scaffolding (deletes), real logic (rebuilds), or data movement (becomes a query).

Two are already inert and should not be rebuilt as-is. The doc extractor dispatcher carries a hard-coded kill switch set 2026-07-07, and the callback scheduler reads a tab that does not exist — with two further defects that would still drop callbacks after the tab name was fixed. Reviving either without reading that file reintroduces both.

The script layer

Six clasp projects on disk at Workflows/clasp/, holding six files — one per project. Two of them — project-e and project-f — did not exist locally before 2026-08-18, which is why the scripts in them were invisible to every prior audit.

Consolidated 2026-08-21: 26 files became 6. Apps Script gives a project one shared global scope and allows one doPost/doGet per project, so files were never modules — a name defined twice silently resolved to whichever loaded last. That is not a hypothetical: installSafetyTimer_ collided between Prep and TPS, TPS won, and Prep's 15-minute safety net was never installed. One file per project makes that collision visible at edit time instead of at runtime.

The pre-consolidation originals are kept at clasp/_pre-consolidation-20260821/ if per-file provenance is needed.

projectfilelines
a — System IntelligenceSystemIntelligence.js4,637
b — PipelineCodexCore.js3,410
c — Doc ExtractorDOC EXTRACTOR Trigger.js512
d — Buyer IntelBuyerIntel.js4,182
e — Codex_PulseCALLBACK SCHEDULER.js383
f — Community CommentsCode.js3 — an empty stub

project-f is an untouched Apps Script default. The Community Comments engine is real and runs as two n8n workflows; none of its logic was ever in Apps Script.

Apps Scripts do not appear in the n8n API at all. Anything script-driven is invisible to a workflow-level audit, which is exactly how the two gaps below stayed hidden — and why How it all talks to itself below exists as its own section.

project-b spans three databases

Its scripts read and write Codex_Core, System Intelligence and Buyer Intel. That is the single largest piece of cross-database coupling in the system, and it exists only inside the script bodies — no workflow, no tab, and nothing in n8n reveals it.

Two projects bind to System Intelligence

project-a and project-c are separate Apps Script projects pointed at the same spreadsheet. Legal and intentional, but it means "which script touches System Intelligence" has two answers.

A fourth trigger type

Every trigger described elsewhere in this document is a cron, a webhook, or a button. Codex_Pulse's CALLBACK SCHEDULER.js adds a fourth: an installable onEdit trigger that fires when a person edits a cell, then schedules a one-time time trigger per row and POSTs to /webhook/scheduled_request.

It is a live script pointing at dormant workflows — see below.


The tabs everything shares

The workflows are separate; the data is not. Five tabs carry most of the traffic and are where coupling actually lives.

TabTouched byRole
Stage6 workflowsThe lead's current state. Read by every responder, written by call and inbound flows.
Nimbus4SMS history — inbound and outbound, both directions in one place.
Intake Governor5Where counters surface for the operator.
Master5The qualified property list.
ZIP Buyers2Deed evidence, buyer-side.

Stage is the real spine. Anything that changes how Stage is written affects six workflows at once, and that coupling is invisible in the n8n UI — nothing draws an edge between two workflows that share a tab.


How it all talks to itself

The workflows and the scripts are not two systems. They are one machine with two engines, and the wiring between them is the part no single tool will show you: n8n cannot see Apps Script, and Apps Script cannot see n8n. Every edge below exists only in code.

Four kinds of connection

KindDirectionVisible in
Shared tabworkflow ↔ workflownothing — inferred from both configs
Script fires webhookscript → workflowthe script body only
Workflow pokes Web Appworkflow → scriptthe workflow's HTTP node
Script's own triggerscript → itselfthe Apps Script UI only

Script → workflow: the nine edges

Nine scripts call n8n webhooks directly. These are hard dependencies, and none of them appear anywhere in n8n.

ScriptDatabaseFires
Retry OrchestratorSystem Intelligence11 webhooks — see below
Prep Lead HandlingCodex_Core/sourcing (ch 9)
TPS TriggerCodex_Core/tps (ch 10)
Master Tab FilterCodex_Core/zillow-batch (ch 11)
Disqualified DOM CheckerCodex_Core/fsbo90 (ch 9)
Buyer Intel ProposerBuyer Intel/buyer-intel-propose (ch 4)
Buyer Intel Approval RouterSystem Intelligence/buyer-intel-message (ch 5)
DOC EXTRACTOR TriggerSystem Intelligencethe v2 UUID webhook (ch 8)
CALLBACK SCHEDULERCodex_Pulse/scheduled_request — dormant target

Read that column again: Act II is not started by n8n. Chapters 9, 10 and 11 are triggered by Apps Scripts sitting on Codex_Core, not by crons inside the workflows. The workflow chapters describe what happens after the POST arrives; the decision to send it is made in a script.

The Retry Orchestrator — the loop the Atlas was missing

project-a/Retry Orchestrator.js (476 lines) is a cross-cutting retry layer that can re-fire eleven different workflows: outbound, inbound-text, cipher, couriernet, calling_all_leads, emailverify, wrong-number, wrong-number-resolved, tps, sourcing, fsbo90.

Its logic closes a loop documented elsewhere in this Atlas as open-ended:

  1. Every 15 minutes, read the domain error tabs — Sourcing, Nimbus, CourierNet.
  2. Find rows with Status=OPEN, Retry_Eligible=TRUE, Retry_Count<2, and a timestamp older than one hour.
  3. POST the row back to the originating workflow's webhook.
  4. On 2xx → mark RESOLVED, and flip the matching Obsidian master row (matched on Execution_ID) to resolved as well.
  5. On failure → increment Retry_Count; at 2, mark WONT_FIX for manual review.

That is why retry_eligible appears in every Format Error → Obsidian node written during the 2026-08-18 audit. Those flags are not documentation — they are input to this script. Every "retry_eligible: TRUE" decision made in that audit was, unknowingly, configuring this orchestrator.

It is not confirmed installed. The file header says the time-driven trigger must be added by hand in the Apps Script UI, and nothing in the code self-installs it. So the retry loop is either running every 15 minutes or not running at all, and that cannot be determined from disk — only from the Triggers panel on the System Intelligence sheet. (See gap #13.)

Trigger confirmed installed (2026-08-20). The 15-minute timer is live, so the retry loop is running: eleven workflows have automatic recovery, driven by the retry_eligible flags set during the instrumentation audit.

Workflow → script: the Web App

The traffic runs both ways. Section Health Aggregation Engine.js is deployed as an Apps Script Web App with doGet and doPost handlers — it is the /exec endpoint that SB: EOD Rollup Poke (ch 31) calls at 23:55.

So the nightly cycle crosses the boundary twice:

n8n workflows -> counters -> Postgres -> Intake Aggregation -> sheet n8n EOD poke -> Apps Script Web App -> reads those sheets -> rollup Obsidian 4:30 -> reads the rollup -> morning brief 5:20

This is also why chapter 31 needed a response-body check rather than an error track: an Apps Script Web App answers HTTP 200 even when it refuses the action.

The second clock

n8n is not the only scheduler. Twelve scripts run on their own triggers, and they do not appear on the clock table elsewhere in this document.

ScriptCadenceDatabase
Buyer Intel Consolidatorevery 10 minBuyer Intel
~~Section Health Aggregation~~retired 2026-08-24 → Ch 35 SB: SECTION HEALTH—
Retry Orchestratorevery 15 min (if installed)System Intelligence
Prep Lead Handlingdaily 05:00 + 06:00, plus a 05:30 sweepCodex_Core
Proposals Writerdaily 06:00 + heartbeat tickSystem Intelligence
~~Disqualified DOM Checker~~retired 2026-08-24 → folded into Ch 24 SB: ZILLOW BATCH, cron 09:00—
~~Nightly Cleanup~~retired 2026-08-25 → folded into Ch 18 SB: GHL IMP, cron 22:05—
Buyer Intel Proposerdaily 08:00Buyer Intel

Four of these are gone as of 2026-08-25. runOEHDedup also folded into SB: OBSIDIAN. What each fold deleted is the same shape every time: a property-store mutex n8n does not need, a whole-tab getDataRange() read to answer a question that is now a WHERE clause, per-cell setValue round trips, and a Utilities.sleep() inside a row loop that capped the script at a handful of rows before the 6-minute execution wall. | TPS Trigger | daily 22:00 + nightly cleanup | Codex_Core | | Approval Router · Drill Down · DOC EXTRACTOR Trigger · CALLBACK SCHEDULER | on edit / on change | various |

The 5 AM cluster is busier than the workflow clock suggests. Prep Lead Handling runs at 05:00 and again at 06:00 with a 05:30 row sweep, landing in the same window as Obsidian (04:30), Heartbeats (05:00), Funding (05:05), the brief (05:20) and GHL IMP (05:45). Six of those are n8n; one is a script — and nothing coordinates between the two schedulers.

Operator buttons that live inside the spreadsheets

TPS Trigger.js installs a custom menu on Codex_Core — a control panel with no presence in n8n at all:

  • ▶ Start queue NOW (send batch to n8n)
  • 📅 Install daily 8 AM auto-start + 9:30 PM auto-stop
  • ⏸ Remove daily triggers
  • 🔧 Install onEdit auto-queue trigger
  • 🌙 Install 10:05 PM nightly cleanup · ⏹ Remove it · 🧹 Run it NOW
  • Backfill: queue all blank rows (Discovery + Error)
  • Retry: re-queue all FAILED + stuck RUNNING

Those menu items install and remove triggers. The behaviour of chapter 10 can therefore be changed by anyone with edit access to the spreadsheet, without touching n8n, and with no record in the workflow history.

The honest summary

Three things follow from all of this, and they are the reason this section exists:

  1. Act II is script-driven. Its workflows are POST targets. Reading only the workflow chapters gives you the second half of each story.
  2. The retry layer is real and undocumented. Eleven workflows can be re-fired by a script, driven by flags this audit set without knowing they were live input.
  3. There are two schedulers. n8n's crons and Apps Script triggers both fire into the 5 AM window, and neither knows about the other.

The browser layer

The third engine. Where a county clerk portal, XLeads, ListSource or a vendor billing page has no API, the work is done by a headful Chromium driven by Playwright — a real browser, on a real desktop, signed in as you.

This layer is invisible to both prior audits. n8n sees only an HTTP call to a sourcing host; Apps Script never sees it at all. And unlike the other two engines, it cannot run unattended on a server: several of these sites detect headless browsers, so the scripts run headful against persisted browser profiles on the Windows box during operator hours.

Two hosts, not one

HostRuns onServes
win-sourcing.arcosai.ioWindowsthe main sourcing server — scrape, skip-trace, heartbeats, buyer pull
sourcing.arcosai.ioMacthe always-on half — Cipher inbound/write, community comments
tps.arcosai.ioWindowsa separate service — /tps-enrich, /cbc-enrich, /health

TPS having its own tunnel and its own Express server is deliberate: it is the one target that actively fights automation (Cloudflare + DataDome), so it is isolated from everything else.

Shared plumbing

Before the individual scripts: three things they hold in common, because those explain most of the failure modes.

xleads-auth.js is the shared login gate. Nine scripts depend on the XLeads session — three require() the module directly (comp-lookup, ghl.import, skool-discovery); the rest drive a profile it authenticated or probe it (heartbeats, server, skip-trace, xleads-pull, email-metrics-scraper, semantic-tagger). It handles the double gate — GHL first, XLeads second — and retrieves the verification code over Gmail IMAP from either of two accounts. When logins break, they usually break here, for all of them at once.

Ten persisted browser profiles hold the live sessions: .browser-profile (county scraper), -skiptrace-v2, -buyerpull, -xleadspull, -emailmetrics, -listsource, -tax, -zillow, -openai-real, -captainverify-real. A stale cookie in one of these looks exactly like the target site breaking.

These profiles are not on the Windows box. They are runtime state on the host that owns the fleet, and a check run from the vault will report every one of them missing — which says nothing about whether they exist. The same is true of any Playwright the Mac side controls: absence from this repo is not evidence. Verify a profile on the host that opens it, or through PW 4 heartbeats, which is the probe built for exactly this question.

Anti-bot handling differs per target, and that is deliberate:

TargetTechniqueWhy
County portalsplaywright-extra + stealth + 2CaptchaTyler Technologies sites serve AWS WAF CAPTCHAs
TPS / CBCpuppeteer-real-browser (not Playwright)real Chrome over CDP beats Cloudflare at the TLS layer
Skool, XLeadsstealth pluginlighter detection
GHLplain Playwrightno bot defence, just auth

Wired — reachable from n8n

Twelve browser automations sit behind a sourcing-server route, so an n8n workflow can start them.

scraper.js — the county document scraper

637 lines · headful · stealth · 2Captcha · /scrape

The single most-called automation in the estate. Chapter 7 fires it 42 times per cycle — once per document type per county — and each call drives a real browser through that county's clerk portal to pull filings: probate, lis pendens, divorce, mechanics liens, tax liens, abstracts of judgment.

It carries a 2Captcha integration because Tyler Technologies portals serve AWS WAF CAPTCHAs, and human-like random delays (base ± 30% jitter) so the request pattern does not read as a machine.

Pipeline position: the very first step. Every lead in the business starts as a row this script scraped.

/scrape-publicsearch — the second document scraper

server.js · headful · /scrape-publicsearch · per-county

The other front door to the raw documents. Where scraper.js drives the Tyler Technologies clerk portals, this drives the *.publicsearch.us family — Bell, Comal, Guadalupe, Nueces, Jefferson, Brazos, Grayson — the counties whose records live on PublicSearch rather than Tyler. Same job: pick a document type (lis pendens, probate, divorce, mechanics/tax liens, abstract of judgment) and a date window (default a 90-day rolling pull), walk the results, and drop the filings + screenshots into sourcing/<doctype>/<county>-county/<window>/. Runs async with a callbackUrl, and pushes what it finds to the Processing sheet.

Pipeline position: the very first step, for PublicSearch counties. It sits beside scraper.js at the mouth of the pipeline — a filing enters through whichever portal its county uses, then both roads run on to /lookup-address.

/lookup-address — the CAD address & subdivision resolver

server.js#handleBellLookup (+ per-county) · subdiv-resolver.js · headful · 2Captcha · /lookup-address · .browser-profile

Upgrades a scraped filing into a callable lead: given a document's extracted legal description, it drives the county appraisal district's esearch portal (cssi_esearch — Bell, Hays, Comal and every county on that CAD engine) to return the confirmed owner name, situs address, and account for the parcel.

The hard part is the legal description. The vision extractor (chapter 8) emits prose — "LOTS 258 AND 259, SHERWOOD SHORES VII SUBDIVISION, PECAN GROVE SECTION, BELL COUNTY, TEXAS" — but the CAD searches by a 7,502-row subdivision gazetteer. A brittle regex used to mis-match on boilerplate (everything → "TEXAS TRAILS"), and the whole pipeline completed 1 lead in 3,972. It is now a two-LLM resolver (subdiv-resolver.js): a Proposer extracts the clean subdivision + lot + block from the prose, ranked retrieval shortlists the gazetteer, and a Picker matches — with an agentic retry that, on an esearch lot/block miss, is told which entry failed and re-picks a sibling/base variant. Bell strike rate went 40% → 85%. Guardrail: low Picker confidence → needs_review, never a wrong owner.

Pipeline position: the middle of chapter 8, between extraction and skip-trace. The Doc Extractor's vision reads the document; this resolves who and where; only then is there an owner and address for skip-trace.js to turn into a phone number. Misses land in needs_review — the diagnostic pile the operator works down.

/process-tax — the tax-delinquent lead source

server.js · /process-tax · profile -tax · per-county

The second lead source, parallel to the document scraper. Where scraper.js pulls court filings and /lookup-address resolves them to a parcel, this ingests a county's delinquent tax roll directly: for Bell it downloads the BellCAD delinquent-roll Excel; for other counties it drives the tax portal. It qualifies the rows and writes a xleads-import.csv — the exact file skip-trace.js uploads to XLeads for enrichment.

Pipeline position: the second road into Act II. The document path (scraper.js → /lookup-address) and this tax path are the two ways a property enters — and they converge on the very next step, skip-trace.js, which turns either one's address into a phone number.

skip-trace.js — owner contact enrichment

2304 lines · headful · /skip-trace · profile -skiptrace-v2

The largest script in the estate. It logs into GHL, navigates to the embedded XLeads Sourcing tab, uploads a CSV of properties, waits for processing, and downloads the results. Two login gates (GHL, then XLeads), with the verification code pulled from Gmail over IMAP.

Pipeline position: between a property and a person. Chapter 10 and chapter 14 both depend on it — an address becomes a phone number here, and without it there is nobody to text.

/zillow-check — is it actually a house

server.js · headful · /zillow-check · profile -zillow

A residential-verification probe. Given a resolved address it opens Zillow's property page to confirm the parcel is a single-family home worth calling — and pulls value context — rather than land, a duplex, or a commercial parcel that would waste a dial.

Pipeline position: chapter 11's engine (SB: ZILLOW BATCH). A filter between address resolution and the calling pool — it keeps non-residential and raw-land rows out of the leads that reach a person.

xleads-buyer-pull.cjs — the cash-buyer population

1306 lines · /buyer-intel/scrape · profile -buyerpull

Pulls the cash-buyer population for a ZIP. Its own header is explicit that the purpose is population profiling, not individual verification — feed the rows into a sheet, read the distribution across beds, price, sqft and year, and the optimal filter set falls out of where the mass sits.

Two deliberate constraints, both documented in the file:

  • It scrapes the rendered DOM rather than using Exports, because Exports spends credits from the same 2,500/month pool the seller-lead pulls need. Reading a page the operator is already entitled to view costs nothing.
  • It reads the DOM, not screenshots. OCR on a table of dollar figures misreads digits, and a mangled price silently corrupts the distribution.

Pipeline position: chapter 3's data source, and the origin of the ZIP buy box the whole buyer-first loop is built on.

heartbeats.js — is the fleet still logged in?

361 lines · headful · /heartbeats/fire-all

Not a general system health check. It probes whether the Playwright fleet's browser sessions are still authenticated — the one failure mode nothing else can see. Each probe either opens a persisted profile and confirms the target URL does not redirect to a login page, or calls a credential-gated API the script depends on. No actual scrape work is performed; the probes are deliberately lightweight.

Returns seven rows matching the System Intelligence › Heartbeats schema, with a status taxonomy where OK means the script's environment is healthy.

Pipeline position: chapter 30's engine. When Heartbeats shows a script degraded, this is what noticed — and what it noticed is almost always an expired cookie, not broken code.

tps-enrichment/scraper.js — the Cloudflare fight

1427 lines · own host tps.arcosai.io · /tps-enrich

End-to-end TPS enrichment for a single lead. Rewritten 2026-04-10 to use puppeteer-real-browser instead of playwright-extra + stealth — real Chrome over CDP beats Cloudflare at the TLS level, confirmed by a smoke test that lives beside it as diag-realbrowser.js.

Takes a street, city/state/zip and a name; returns a phone and email with a match score. Its status taxonomy is finer than most: OK (phone and email), PARTIAL (one or the other), NO_MATCH (results, none scoring high enough against the target name), NO_RESULTS (nothing at all).

Pipeline position: chapter 10's enrichment engine, on its own tunnel and its own Express server precisely because it is the one target actively fighting automation.

email-metrics-scraper.js — the gap GHL will not report

707 lines · headful · /brain/email-metrics · profile -emailmetrics

Scrapes the GHL Email Analytics dashboard for delivered and failed counts. It exists because GHL's workflow triggers do not expose deliverability — the data is on a dashboard and nowhere in an API.

Pipeline position: feeds Cipher's brain, which is how the morning brief can say anything about whether yesterday's email actually landed.

openai-balance-hybrid.js — two sources, one number

440 lines · /brain/email-metrics · profile -openai-real

A hybrid by necessity. The Admin API gives a precise token breakdown but lags about 24 hours on cost and returns null for the current balance; the dashboard scrape gives today's running spend and the live balance but needs a logged-in session. Each pit stop captures both and computes today's spend as live_now − api_finalized.

Pipeline position: chapter 32's OpenAI vendor. This is the workflow where reading the wrong field once made spend read zero for weeks.

skool-discovery.js — community reconnaissance

528 lines · stealth · /community-comments · shares xleads-auth.js

Logs into Skool, walks every community the operator belongs to, enumerates channels, and pulls a recent window of posts per channel.

Its header still describes it as "Phase 0 discovery" — dump the landscape to a markdown report, no scoring, no Telegram, no Postgres. It was written to design a rubric against real data. It is now wired into the live /community-comments route and feeds the production pipeline, with skool-ranker.js doing the scoring it deliberately omits.

Pipeline position: chapter 34 → chapter 33. The pull button starts it; JV Signals receives what it finds.

Pearl — the GHL importer

777 lines · own service on port 3456 · runGHLImport(csvBuffer, fileName)

Its own Express service, separate from the sourcing server. Drives the GoHighLevel bulk-import UI end to end: navigate, auto-login (reusing xleads-auth.js and its Gmail verification-code retrieval), upload the CSV, map 17 fields, apply the WNL tag, submit. Returns an imported count and a batch id.

Rebuilt from scratch 2026-06-09 after the original was lost during the Windows→Mac migration cleanup.

It exists because GHL's import has no API worth using. The type-and-Enter pattern inside is the answer to naive-ui dropdowns, which ignore ordinary select calls.

Pipeline position: the last mile of Act III. Chapter 15 stages leads and imports them by API where it can; Pearl is what handles the bulk CSV path through the UI.

Standalone — operator-run

Thirteen more real automations are not wired to anything — run by hand during research and county onboarding. (A further ~18 files in the same folders are diagnostics, probes and superseded copies, which is why a raw file count reads much higher.)

  • ListSource — listsource-county-zips.cjs (reads /county-buy-box), listsource-discover.cjs, listsource-test-buybox.cjs. The wizard preview is free and these stop before commit; nothing here ever purchases.
  • XLeads — xleads-discover.cjs, xleads-enrich.cjs, xleads-probe-filters.cjs, xleads-pull.js (superseded by buyer-pull).
  • County onboarding — stages/02-county-discovery/discover.js and crawl-doc-types.js, the ICM Factory layer that learns a new county's portal.
  • County one-offs — hays-download.cjs, scrape-comal-rom.js.
  • Comps — comp-lookup.cjs / .js.
  • Diagnostics — the diag-* family in tps-enrichment/, plus skool-probe-comments.js and probe2.cjs.

Why this layer constrains the architecture

Three consequences follow, and they explain decisions recorded elsewhere:

  1. Scrapers stay on Windows. The Mac is the always-on host for Cipher, but these need a real desktop session with logged-in browser profiles. That is the whole reason the Mac migration split the estate across two machines rather than moving everything.
  2. Operator hours are a real constraint. A headful browser needs the Windows box awake. Anything depending on /scrape, /skip-trace or /tps-enrich only works roughly 7 AM–9 PM.
  3. Persisted profiles are load-bearing. .browser-profile-skiptrace-v2, -buyerpull, -emailmetrics and friends hold live sessions. Losing one means re-authenticating by hand, and a profile going stale looks exactly like the target site breaking.

Host reality (MD Cypher, direct observation 2026-08-18): the production sourcing fleet above — /scrape, /lookup-address, /skip-trace, /process-tax, /zillow-check, /heartbeats, /buyer-intel, /community-comments, email-metrics — plus Pearl now run on the Mac (sourcing.arcosai.io → Mac:3042, ghl-import.arcosai.io → Mac:3456), not win-sourcing. The "Two hosts" table above predates the migration; the Mac's Playwrights are now inventoried inline in this section rather than separately. The headful-desktop and operator-hours constraints still hold — they just apply to the Mac Mini now.*


Paused, dormant & reopened

One system sits switched off by standing decision; another was paused and has now reopened. Both are recorded here because a published-only view would show the second as simply appearing on 2026-08-18 with no explanation.

Buyer intel — PAUSED 2026-08-20, ACTIVELY WORKED AGAIN BY SEPTEMBER

No longer quiet. The pause recorded below was real and deliberate at the time, but by September the pipeline was back under active development — capture is live through the Buyer Intel Extension (Copy/Post both confirmed working end to end in operator sessions), dispatch-floor tuning shipped 2026-09-01/02, and SB: BUYER INTEL PROPOSER COMPUTE had fixes landing as recently as 2026-09-23 (a retryOnFail add and an execution-order race fix, both against real errors in obsidian_error_handler). This is not the same thing as saying the pipeline is healthy — see the honest caveat below — but it is not sitting off by decision the way this section originally described.

The one number that still tells the true story: public.zip_dispatch reads 0 rows as of the most recent check on record (2026-09-05). Buyer capture works — 205 verified buyers across 30 ZIPs as of that same check — and the dispatch step that would turn ZIP interest into a scraped county batch has never fired. Whether that is because the restart mechanism below was never actually installed, or because SB: BUYER INTEL PROPOSER COMPUTE's own inactive-for-an-unknown-stretch gap (surfaced during the 2026-09-23 n8n reactivation triage, see The move to Postgres) swallowed it, is not resolved in any source this Atlas draws on. Stated plainly rather than guessed at: this Atlas cannot currently confirm whether the hourly ZIP Dispatch.js trigger described below was ever installed. Treat the restart mechanism as unverified until someone checks the Apps Script triggers directly.

Why it was paused, for the record. The operator took it down 2026-08-20 while working through roughly 35 repairs, reasoning that fixing error paths while leads flow means testing every change against live outreach — and a mistake there costs real contacts, not just a failed run.

SB: BUYER INTEL ZIP LOOP was archived 2026-08-18 (HiZcF5d7KAanKkcO, 6 nodes, alive five days). It was the hourly driver — 0 0 8-20 * * 1-6 — that read ZIP Ranking, claimed un-scraped target ZIPs, and POSTed a county batch to win-sourcing.arcosai.io/buyer-intel/scrape.

It was superseded by ZIP Dispatch.js (Apps Script, project-d-buyer-intel), which hits the same endpoint with the same claim ledger: CLAIMED + a six-hour staleness window + one county per batch. The n8n copy said so itself — "mirrors the eligibility rules in ZIP Dispatch.js deliberately rather than calling it, because Apps Script is only reachable through a deployed Web App." A stopgap, correctly retired: two dispatchers sharing one claim ledger can both claim the same ZIP and fight over the XLeads session. Nothing else in the estate calls that endpoint.

⚠ THE SWITCH THAT HAD TO BE FLIPPED — installation status unverified

Archiving the loop removed the hourly automatic trigger. ZIP scraping was meant to run only when someone calls ZIP Dispatch.js by hand unless this trigger was installed:

Function
startNextBatch — the county argument is optional, so it runs fine with none (all counties, ordered by raw sales instead of per-county rank)
Event source
Time-driven
Type
Hour timer, every hour

One behaviour change to expect if it was installed. The n8n loop was restricted to 8am–8pm Mon–Sat. Apps Script's hour timer has no such window, so it would fire overnight and on Sundays — the operator's standing closed day.

Also verify: sortMasterByScore (Codex_Core) — the non-alert function. sortMasterByScoreWithAlert pops a confirmation dialog that nothing can dismiss on a timer.

What the September dispatch-tuning work actually changed, separate from the trigger question above: MIN_ZIP_HITS (readout-only, unchanged at 2) was split from a new MIN_DISPATCH_HITS = 8 (the real XLeads-spend gate), and a dispatched ZIP's exclusion window was shortened from permanent to a rolling six months. Both shipped and were verified by extracting and running the real selection logic against the live buyer distribution, not by reading the code and assuming. Whatever is gating dispatch at zero, it is not these two constants — they were proven to select four real ZIPs for dispatch against the 2026-09-01 distribution.

The document pipeline — REOPENED 2026-08-18

WorkflowNodesChapterState
SB: PROCESSING SHEET WRITER1557active
SB: DOC EXTRACTOR v2558active
SB: DOC EXTRACTOR56—inactive (v1, superseded)

These are responsible for bringing leads into the system, and they were switched off deliberately while the pipeline was edited — to stop new leads processing through to GoHighLevel and starting calls before the operator was ready.

That valve reopened on 2026-08-18, along with SB: GHL IMP (chapter 15). All three now run, and the intake path is live end to end: documents scraped → vision-read → validated → enriched → staged on Prep → imported to GHL at 5:45.

They are documented in Acts II and III as chapters 7, 8 and 15 rather than here. This section records only that they were paused, and why — because several counts elsewhere will show a step change dated 2026-08-18 that has nothing to do with anything failing or being fixed.

SB: DOC EXTRACTOR (v1) stays inactive as v2's predecessor.

The automated caller stack — off by design

WorkflowNodesDatabaseLast changed
TXT & Call256Codex_PulseMar 2026
SB: CALL EXE30Codex_PulseMar 2026
SB: OBD CALL PREP16Codex_PulseMar 2026
SB: CALL INIT & CLN11Codex_PulseMar 2026
SB: CALLBK9Codex_PulseMar 2026

Automated outbound calling and callback scheduling. Deliberately inactive — the operator is not running automated callers at present. Unlike the pipeline above, this is a standing decision rather than a pause.

TXT & Call at 256 nodes is by some distance the largest workflow ever built here — nearly three times SB: EMAIL IND. It carries eleven trigger nodes, including /outbound and /inbound-text, which are the same paths SB: TXT OBD and SB: TXT IND serve today. It is the predecessor those two were split out of, not a competitor for the same traffic.

The live dangling edge: Codex_Pulse's Callback Scheduler is an active script on an active spreadsheet that POSTs to /webhook/scheduled_request — a path served only by TXT & Call and SB: CALLBK, both inactive. Editing the CallBack Request tab today schedules a trigger that fires into nothing. Harmless while the stack is intentionally off, and worth knowing before anyone wonders why a callback never happened.


The clock

Time (CT)What fires
03:00Intake Retention — prune
04:30SB: OBSIDIAN — nightly synthesis (Mon-Sat)
05:00Heartbeats — 8 probes (Mon-Sat)
05:05Funding Tracker — pit stop 1
05:20Cipher morning brief (outside n8n)
05:45SB: GHL IMP — leads into GoHighLevel (Mon-Sat)
09:00–11:00Emails — GoHighLevel, outside this Atlas
09:30Funding pit stop 2 · Intake Aggregation
11:00–14:00Texts — GoHighLevel
14:15Funding pit stop 3 · Intake Aggregation
16:00–20:00Manual outbound calls — GoHighLevel
19:50Funding pit stop 4
19:55Intake Aggregation
23:55EOD Rollup Poke

The 5 AM cluster is timing-critical and the order is load-bearing: Obsidian synthesises at 4:30 using data that must already be complete, heartbeats confirm liveness at 5:00, funding checks the balance at 5:05, and the operator is briefed at 5:20 on all of it. Anything that slips earlier in that chain silently degrades the brief rather than failing visibly.

The working day is not in this document. Between the 05:45 import and the 19:55 pit stop, the italic rows above are the actual business — emails, texts and calls to real people — and every one of them is implemented by the 56 active GoHighLevel workflows n8n cannot see. n8n prepares the leads; GoHighLevel works them. See ghl-decomposition.md; those workflows become Book Two of this Atlas as Phase 3 brings them home.

Mon-Sat, not Mon-Fri. Saturday is a workday; Sunday is closed. Crons use 1-6, and holiday skips are handled by Cipher writing 0 to Daily Intel column J.


How failure is reported

Standardised across 14 workflows on 2026-08-18.

Not to be confused with how Cipher remembers. Everything in this section is n8n's own error-reporting standard — what a workflow does when a step fails. Cipher's separate daily consolidation act, which decides what she carries forward from one day to the next, is documented in Cipher Daily Reset (after The HUD, near the end of this Atlas). The two systems share nothing — one writes rows to obsidian_error_handler, the other writes a markdown log and strips her own memory buffers — and a workflow failure and a memory-consolidation failure look nothing alike, so do not read this section as covering both.

The rule for where a track goes — confirmed against SB: TXT IND, the reference implementation:

An error track belongs on nodes that hand data to something external that can fail on its own: a Sheets write, an HTTP call, Postgres, an LLM agent. It does not belong on internal logic — code, switch, merge, if, wait — which fails loudly by itself and is covered by the crash handler.

The GoHighLevel boundary. Four workflows — SB: TXT OBD, SB: FOLLOWUP GENERATOR, SB: CALL EXT - GHL and SB: EMAIL IND — carry the crash handler but few or no per-node error tracks, and that is deliberate. They are the near half of a two-half system: they prepare and hand off, while the actual sending, cancellation and retry logic lives in roughly 33 GoHighLevel workflows that n8n cannot see and this Atlas does not cover. Errors on that side are already caught and routed back to SB: OBSIDIAN.

Adding per-node tracks here would duplicate an existing catch and give the appearance of instrumenting a pipeline whose other half stays invisible. The condition changes when those workflows are pulled into n8n — at which point they become instrumentable like everything else in this document.

The one documented exception: fire-and-forget notifications to Cipher (/cipher-inbound, /cipher-write) stay bare. Nothing downstream depends on them landing. Six such calls in TXT OBD and two in TXT IND are correctly untracked — an early version of the audit script wrongly counted them as gaps and inflated every number.

Three layers, three jobs:

LayerAnswersMechanism
Emit Counter (volume)how many went throughPOST /intake-event
Emit Counter (errors)how many failed at this stepfed by Format Error
Format Error → Obsidianwhich one, which node, what messageexecuteWorkflow → Entry B
settings.errorWorkflowthe whole thing diedn8n → Entry A

The collector's own ceiling

SB: OBSIDIAN is write-limited, not logic-broken. Measured 2026-08-20 across 1,500 executions (7–20 Aug): 1,037 failed, almost all Google Sheets rate limits on Append to Obsidian Tab, plus a 950-execution burst on 7 Aug where Append to Intake Governor Tab lost its connection.

DayFailed
08-07950
08-128
08-142
08-172
08-1865
08-192
08-208

Every Sheets node already carries retryOnFail: true, maxTries: 3, waitBetweenTries: 2000. These are the executions that exhausted all three retries — so the standard mitigation is present and insufficient.

The failure mode is worst exactly when it matters most: many workflows failing at once is precisely when the collector gets rate-limited and drops the reports. The errors happen; the record of them does not. The same ceiling blocks every bulk upload.

This is not scheduled for repair. Sheets is being replaced — the queued Google → Supabase migration is the fix, and Sheets-side mitigations (batching appends, longer backoff) would be workaround tax on a store on its way out. The counter path already proves the point: observability.intake_events carries the same estate-wide load through Postgres without this failure.

Until the migration lands, the Obsidian watchdog (Ch 30, Section Health) makes these bursts visible — it runs on Google's scheduler, a separate failure domain from n8n, and alerts through the Cipher bot directly. Note its shape: it reports executions newer than the last one alerted, capped at 10 lines, so a 950-execution burst arrives as one message, not a thousand.


Standing gaps

Ordered by what they cost.

(none open — the last six were closed 2026-08-20.)

The remaining known weakness is not a gap in a workflow but a dependency: SB: OBSIDIAN logged 1,037 failed executions in two weeks, almost all Google Sheets rate limits, with three-try retries already in place. That is answered by the queued Google → Supabase migration rather than by a repair — see “The collector's own ceiling” above.

Three gap classes

Named during the second survey. These are shapes, not single fixes — each one recurs across several chapters, and closing the class is worth more than closing any individual instance.

Counted-intent-not-confirmed-write

The estate emits counters named X_written that actually count X_requested. The counter fires when the request is made, not when the row lands — so a silent write failure shows as healthy volume.

This is the exact shape of the four-week Section Health blackout. Known instances: phones_poisoned (Ch 12), emails_repaired (Ch 13), leads_imported (Ch 15), and roughly three more.

The receiver end of that blackout is now closed — Ch 27's Assert Row Landed reports a dropped insert instead of answering 200 and saying nothing. The emitter end is not: these counters still fire on request rather than on confirmed write, so they would report healthy volume into a receiver that is correctly reporting drops. Closing the class means moving each counter to after its write, one chapter at a time.

The fix pattern: SELECT after write and emit X_verified alongside X_requested. Divergence between the two is the tell.

Silent-vendor-response

A 200 with an empty or refusing body counts as green. The vendor answered, so nothing errors, and the workflow proceeds on nothing.

Ch 31 already solved this with Check Rollup Response, which reads the body and throws. Nobody else uses the pattern — TPS answering 200 on a block page (Ch 10) and freeBusy returning empty (Ch 24) are the same shape.

The Apps Script clock is un-audited

Twelve triggers across six projects, one shared auth gate (xleads-auth.js, depended on by nine scripts), one dangling edge (the Callback Scheduler POSTing to an inactive workflow), and cron collisions with n8n's own schedule.

No chapter owns its health. The clock section documents n8n's schedule; the second scheduler is described in How it all talks to itself but nothing monitors it. A Gmail IMAP failure would take out nine scripts at once with no probe watching.

Every gap above also appears in its own chapter. Edits are a two-touch: chapter subsection plus this row.

Last regenerated: 2026-08-20.

Both blocked items unblock the same way: clasp logout && clasp login on the System Intelligence Apps Script project.

---|---|---| | Toast reported intent, not outcome | SB: BUYER INTEL | Fix K — an Await All Writes merge node now sits between the four sheet appends and Build Response, so it fires once, downstream of every write. ok derives from whether rows actually landed | | Span-delete could take other ZIPs' rows | ZIP INGEST | Fix J — the Sheets delete is replaced by a batch code node deleting the specific stale_row_numbers, sorted descending so indexes do not shift | | Two workflows claimed POST /write | PSW · DOC EXTRACTOR v2 | PSW renamed to /write-processing; v2 owns /write outright | | Transposed hostname | SB: TXT IND | leadconnecorthq → leadconnectorhq | | Unwired fallback output | JV SIGNALS | Route by tab now has both outputs wired | | Dead workflow still published | BUYER INTEL VERIFIER | Deactivated | | codex-postgres restart policy no | VPS Docker | Now unless-stopped; verified across a reboot | | OBSIDIAN Sheets-append flakiness | SB: OBSIDIAN | Retry-on-fail: 3 tries, 2s backoff, applied across sibling nodes | | Funding Tracker shape mismatch | Intake Event Receiver | Emitter corrected | | Stale alert queue | OEH sheet | 121 rows bulk-resolved |

Two runtime verifications still outstanding. Fix J and Fix K were both confirmed correct by topology but have not yet run against live data. Fix K shows itself on the next Facebook capture — if a sheet write fails, the Stream Deck toast should now say ok:false and name the write that missed.

One watch-item from the /write rename. If any caller was POSTing to /write expecting PROCESSING SHEET WRITER to answer, it now reaches DOC EXTRACTOR v2 instead. The tell is PSW's webhook path not firing when it should; the fix is to point that caller at /write-processing.


Newer systems, since the audit

Five pieces landed after the 2026-08-18 audit that do not sit inside the six acts — either because they are client-side tooling rather than n8n workflows, or because they belong to a different part of the operator's life entirely. Recorded here rather than folded into the numbered chapters so the Act structure above stays undisturbed.

The buyer's-side toolchain — Buyer Intel Extension

The client that actually performs the capture Chapter 1 receives.

Chapters 1-5 describe what happens once a Facebook thread reaches n8n. The Buyer Intel Extension is what gets it there: a Manifest V3 Brave extension (Workflows/buyer-intel-extension/) injected into facebook.com, with two buttons — Copy (blue) runs a post through a pure-logic rule engine and writes a structured capture to the clipboard; Post (green) pushes it to SB: BUYER INTEL (Ch 1) and shows live per-operator counts.

The rule engine, and why it stays this narrow. Only two conditions produce a red (refused) capture: an exact repeat post, and no resolvable ZIP. ZIP resolution runs a fixed ladder — body ZIP, then the poster's own comment, then two distinct commenters independently agreeing, else unverified. County coverage is a router, never a gate: a ZIP in an already-covered county still gets captured, because coverage only decides whether the county gets proposed, not whether the buyer is worth having on file.

Built, then hardened against its own account. The first build week found and fixed real defects rather than cosmetic ones: cross-file const was not shared between content scripts, so every click threw silently; the toolbar icon's self-heal re-injected scripts and produced duplicate dedup memory; Facebook's comment count badge is total interactions (likes + shares + comments combined), not a comment count, which cost two rounds of pointless timeout tuning chasing a number that could never be reached. Two actions were taken on the operator's own live Facebook account by the tool's early mistakes — a like fired by a comment-count matcher accepting a bare number, and a lost session from following a timestamp permalink off the page. Both closed the same way: expanders() now hard-refuses any anchor or anything labelled like/share/react/send/follow/save/hide/report, structurally rather than by remembering not to click it.

The wrong-post bug took four diagnoses to actually fix (2026-08-30). Reported as "Copy captures the post after the one I picked." The real cause was the fourth guess: an open post renders as a [role="dialog"], and the dialog-detection path required a story_message/ad-comet-preview hook that an opened post often carries neither of — so the fallback never fired because the feed behind the dialog always supplied a non-empty candidate list first, and the button silently read through the dialog to the post underneath it.

Two gates, both refuse before the clipboard is written. An age gate (MAX_POST_AGE_DAYS = 365 — buyers on a two-year-old thread have long since bought) reads three date sources best-first and, deliberately, never blocks on an unknown age. A duplicate gate calls /webhook/post-seen and now refuses outright rather than warning — an earlier version let a duplicate still reach the clipboard, which meant it could still be posted anyway.

Current status, as of the most recent operator session on record (2026-08-30): Copy and Post both confirmed working end to end, including the age gate, duplicate gate, session timer, scoreboard counters and a persisted "last post" line, all operator-verified during live capture.

Known gaps.

  • The extension only ever sends capture_type: "thread". Poster and commenter capture modes exist in the underlying workflow and in the Stream Deck .bat scripts but have no button — which is the reason public.posters stays empty.
  • /webhook/city-zip defaults to state=TX and the extension never sends the parameter; fine today, must be wired before sourcing leaves Texas.

GHL Inventory Scraper

A one-time snapshot of everything GoHighLevel holds, taken the moment the operator begins migrating off it.

Design status: spec written 2026-09-10, not yet executed as of this Atlas. A Playwright script (forked from the existing skip-trace/XLeads auth pattern already proven in the scraper fleet) walks every GHL configuration surface once — custom fields, custom values, tags, pipelines, business profile, every automation workflow's full builder canvas, one representative contact record, calendars, snippets and templates, integrations, phone numbers, and the agency-level sub-account index — and dumps the raw HTML plus a full-page screenshot of each, indexed by a crash-safe manifest.json.

Deliberately not a drift monitor. The base data model gets built once from this pull and evolves in-house afterward; if GHL changes after the snapshot is taken, that is no longer this estate's concern. No parsing happens in the scraper itself — every prior attempt to parse GHL's DOM inline broke when GHL changed its markup, coupling extraction to the walk. Three downstream parsers (a CRM schema builder that emits an ALTER TABLE public.master script, a workflow-spec inventory that becomes the source for the n8n rebuild, and a template/snippet extractor) are explicitly not part of this scraper and are to be built once the snapshot exists, against real captured HTML rather than assumption.

This is the reconnaissance step behind the same move Chapter 3 of this Atlas already documents elsewhere in miniature — the ~56 active GoHighLevel workflows this document cannot see becoming Book Two as Phase 3 brings them home. The self-owned CRM mimic named in the 2026-09-12→23 session (part of the four-piece consolidation, see The HUD) is a downstream consumer of exactly this kind of walk, built from a real 9-page Playwright pull against the operator's live account rather than from this spec — the two efforts are closely related and worth reading together if either is picked back up.

Operator Shield

A machine-layer accountability circuit, designed for the business and shaken down on the household first.

Design status: locked 2026-08-26, targeted to ignite 2026-08-29. Not an n8n workflow — a standalone accountability layer meant to run on every Mac an operator, VA or family member might work on, born from a business need already on the list (anyone pulling Facebook leads on a work machine shouldn't be free to drift into non-work behaviour with no trail) that a household situation forced to the front of the queue on the same night.

The circuit, six steps: a worker opens something they shouldn't → a one-minute warning timer fires → Time Doctor captures what happened during that minute → compliance logs quietly, non-compliance escalates to the operator with screenshots → an attempt to delete the evidence becomes its own, more damning flag than the original offense. The tamper-detection in the last step is the load-bearing one: most monitoring can be defeated by cleaning up afterward, so this system turns the cleanup itself into the strongest signal.

One detection mechanism, three severity tiers. A session-fingerprint watcher hashes each app's local session-token file on an interval; any identity change trips the flag regardless of mechanism — logout/login, account switch, or delete-and-recreate all look the same to it. Response varies by rule: hard block (screen locks immediately, no timer — reserved for a never-list a visit alone constitutes) · one-minute warning (the teaching layer — most of the never-list, and any identity swap on a dual-use app) · silent flag (baseline drift — a domain nobody on the machine has ever hit, logged to an end-of-day rollup with no popup, to catch slow creep without alert fatigue).

Two-week passive baseline before any rule fires. The allowlist is learned from observed traffic, not guessed at, so rules apply against deviation from an actual baseline rather than an arbitrary list.

Per-Mac config over the same shared core. One module, one config file per machine — a work Mac runs the standard tiers, a kid Mac inverts the model entirely to an allowlist (Cipher and school portals are the only permitted set; everything else is treated as intrusion).

Relationship to the daughter device monitoring system below. The household deployment named in this design is the daughter's device — this spec is the broader, Mac-side, business-grade version of the same accountability principle; the daughter system below is the Windows-side implementation that actually shipped first, independently, because the household situation could not wait for the Mac-side design to fully land.

Cipher Daily Reset

Consolidate the day, then restart it — replaces the old handoff-file mechanism entirely.

Shipped 2026-09-01. Cipher's 24-hour consolidation act lives in telegram-bridge/daily_reset.py, called at the tail of run_dream_state (Deep REM, 5 AM Chicago) rather than as a separate scheduled job — REM has already read the day's episodic log to produce its own synthesis, so writing the day's summary there is a second rendering of work already paid for, with no extra model call and no second failure point.

The act, and the safety property that makes it trustworthy: write the day to memory/daily/YYYY-MM-DD.md, and only if that write returns a real path, strip all four of Cipher's thread buffers and clear every session_id. The next morning she boots from that daily log — injected once the live buffer is down to four entries or fewer — instead of replaying the previous day's raw transcript. Nothing is dropped that was not first written down. A quiet day, a malformed extraction, or an unwritable directory all leave the buffers intact rather than silently losing the day.

This is what makes the old handoff-file mechanism obsolete. Before this shipped, state moved between a Cipher session and a Claude Code session through handoff documents written by hand. The daily log is now the shared handoff surface — Cipher reads it every morning, a same-day Claude Code session reads the identical file, and neither has to reconstruct the other's context from a note written for the occasion. (See How failure is reported, above, for the cross-reference the other direction — that section covers n8n's own error tracking, a different system entirely from this one.)

Measured, not assumed. Buffer size before the strip: 195,777 characters (~52,912 tokens). After: 618 characters (~167 tokens). A 316:1 reduction. The cost driver behind the push to build this was concrete: $531 logged 2026-08-22 through 09-01, with cache-write events — 12% of calls, 61% of spend — the dominant cost, each one a 7-13 hour idle gap forcing a full 450k-900k-token rewrite of the conversation cache rather than a cheap read.

Awareness is preserved, not traded away. Per the operator's own standing principle that Cipher must stay aware of every system event, this is explicitly awareness from summary, detail from retrieval — she keeps knowing what happened, she stops re-reading the full transcript of it on every turn. A companion change shipped the same day, topic_map.py, ships an index of topic files (~570 tokens) instead of the files themselves (~89,600 tokens) on every turn, which she opens with Read/Grep only when a topic is actually relevant.

Four layers, all shipped the same day, none live until a pm2 restart cipher: the topic-map index (every turn) · a buffer entry cap (system tasks) · session-id release on the 1.5h/4h idle naps (the fix that mattered most — the two-tier sleep already existed and had simply never cleared the session id) · the 5 AM write-then-strip described above.

Daughter Device Monitoring System

A separate system, unrelated to the lead-sourcing pipeline — parental device monitoring for the operator's 17-year-old daughter. Recorded here because it runs under the same PM2 process manager as Cipher and the sourcing server, and shares real operational surface with them.

Built 2026-08-26, on the Windows PC, under PM2 alongside Cipher and the sourcing-server processes (pm2 save persists it across reboot). Context: the operator's daughter was in an online relationship with an 18-year-old met through Discord/Fortnite, an age-gap situation the operator chose to handle through device-level deterrence rather than outside intervention.

What is live:

  • history-watch — polls all nine browser profiles across five browsers (Brave×3, Chrome×5, Edge, Firefox) every 20 seconds. On a detected deletion it recovers the URL, title and visit time, fires an on-screen popup, and sends a chronological Telegram narrative to the operator's own Comms bot — each line timestamped to when the page was actually visited, so the operator sees dwell time before deletion, not just the deletion itself.
  • A closeable "captured" popup — informational only, dismissible by the daughter; the operator escalates himself if warnings are ignored rather than the tool escalating automatically.
  • Incognito disabled on all five browsers via registry policy.
  • pc: remote lock — texting pc: <message> (plain text, not a slash-command, so it is not filtered by Cipher's own command handler) to the main Cipher chat writes a message that reaches the Windows box via the existing Mac↔Windows sync channel, shows a fullscreen "Message from Dad" prompt, and locks the workstation once acknowledged. Built, and depends on Cipher — which runs on the Mac as primary host — being restarted to pick up the bridge change that reads these messages.

The gap this system's own record names explicitly. Everything above is forensic — it tells the operator what already happened. The stated next want is live: seeing a site load in real time and blocking mid-action from a phone, which is a DNS-layer problem this system does not solve. That gap was reframed 2026-08-26 as its own design, Operator Shield (above) — same underlying principle, generalised from one household machine to every Mac an operator, VA or family member might use, with the household deployment serving as the shakedown environment before the business-grade version ships.

Standing gotchas worth keeping if this is touched again. PM2 replaced two Windows Scheduled Tasks that were still installed and fighting it for single-instance control — those tasks were never confirmed deleted. Browser deletions can sit in memory before reaching disk; the watcher now forces a WAL checkpoint on read to confirm a deletion actually landed rather than trusting an in-memory state.

Where the boundaries are

Three things this document does not cover, and should not be read as covering:

  1. The ~11 GoHighLevel workflows. Everything between "leads land on Prep" and "someone replies" happens inside GHL. n8n does not see it.
  2. Cipher. The bridge, the morning brief, the dream synthesis and the Telegram layer run on the Mac as Python, not as n8n workflows. n8n calls into Cipher; the reverse mostly happens through sheets and queue tabs.
  3. The scrapers themselves. Playwright automation lives on the sourcing servers (sourcing.arcosai.io = Mac, win-sourcing.arcosai.io = Windows). n8n triggers them and consumes their output but does not host them.

35 business workflows · 6 databases · 6 Apps Scripts · 8 browser automations Estate audited 2026-08-18. Script layer, fleet count, headline figures and the Sheets → Postgres state re-verified against live systems 2026-08-25.

Updated 2026-09-24: the HUD, the Buyer Intel Extension, the GHL Inventory Scraper, Operator Shield, Cipher Daily Reset and the Daughter Device Monitoring System added; the Postgres migration status, the Cipher SQL gateway chapter and the buyer-intel pause status revised against live sources. This pass did not re-run the full workflow/database headcount above — n8n's own internal database also moved from SQLite to Postgres on 2026-09-23, separate from the codex-postgres migration this document tracks, and 57 of 86 workflows are confirmed reactivated as of that event; see The move to Postgres for the detail. A fresh full-estate recount is worth doing before the next headline-figures revision.

<!-- atlas-anchor -->

Atlas

  • Primary: GoHighLevel
  • Secondaries: Buyer Intel, Heartbeats, Sourcing Pipeline, TPS Enrichment, Cipher Bridge

<!-- /atlas-anchor -->

Standing gaps.

  • [gaps-query] Query current proposals for this section: /gaps Cipher Bridge (from any Cipher surface).