NOTES — v1.4, 2 August 2026

Why the v1.4 changes exist. Moved out of main.ts because val.town caps a single file at 80,000 characters and the reasoning was worth more than the space it cost inline.

Every item below came from a 14-agent adversarial pre-mortem (7 lenses, each finding handed to an independent refuter: 62 raised, 7 killed, 55 survived). These are the six that were fixed.


R19b — due-first filtering (the real quota fix)

---- v1.4 (2 Aug) — R19b: DUE-FIRST FILTERING. The real quota fix. ------------------------- v1.3 fixed the enrolment scan. The adversarial refuter went further and was right: this loop still made three API calls per contact per run (contact, opportunities, conversations) plus a deliberate 120ms pace. At one test contact that is nothing. At backlog scale it is ~9,600 requests and ~45 minutes per run — past every val.town execution timeout and past GHL's daily cap, and GHL answers sustained overload with 401/403, so the engine would have looked credential-broken while quietly starving.

The fix costs nothing because the answer is already in the blob: isDue() and the sequence offsets are pure functions of state. If a contact's next message is not due, no send can happen this run, so there is no reason to ask the CRM anything about them. On a 12-touch, 3-week path roughly 1-2% of a population is due on any given tick — so per-run cost drops from O(enrolled) to O(due).

Two deliberate safety valves, because "cheaper" must never mean "blinder":

  1. SAFETY_SWEEP_HOURS — every contact still gets a full check at least once a day even when nothing is due, so exits (opted out, became a customer, booked) never go stale for long.
  2. Anyone with no lastCheckedAt, or in a status this shortcut does not model, falls through to the full check. The skip is opt-in per known-safe case, not a default.

R21 — reply detection was blind to any outbound after the reply

---- v1.4 (2 Aug) — R21: an outbound message used to hide a reply completely ---------------- The adversarial pass found the hole and it is a real one on THIS account. This reads a single denormalised field on the newest conversation. Chloe answers the phone, Ava answers Facebook and Instagram, and 165 published workflows send things — so a customer can reply, get an automated outbound seconds later, and the direction field flips to "outbound". The engine then reads "no reply" and carries on messaging somebody who is mid-conversation with the business.

unreadCount is the second, independent signal: GHL increments it on inbound and only clears it when a human opens the thread. An outbound does NOT clear it. So an unread thread means somebody wrote in and nobody has looked — which is exactly the state that must block a send, whatever the last message happened to be. Verified present on 25/25 live conversations, 2 Aug.


R22 — paused_reply had no exit

---- v1.4 (2 Aug) — R22: paused_reply had no exit -------------------------------------- Replying is engagement — it is the outcome the whole sequence exists to produce. But a paused contact stayed paused forever unless a human edited the blob by hand, which nobody was ever going to do. So the most engaged leads quietly became the ones nothing happened to, and they sat in the "active" count making the dashboard look busier than the business was.

Now: if a pause has gone this long with nobody acting on it, the engine raises a SECOND task saying so, and lets them go rather than holding them in limbo. Deliberately does not resume the sequence — someone wrote to this business and got no reply, and the fix for that is a person, not another automated message.


R23 — position is an array index

v1.4 — R23: position is stored as an array index (lastMainStepSent). Insert a message at position 3 and every in-flight contact silently re-points at the wrong step — somebody mid-sequence gets the wrong email and nothing anywhere says so. Recording the step ID alongside the index does not fix the model, but it makes the drift DETECTABLE: on the next send the engine compares what it is about to send against what it last sent, and says so in the journal if the sequence has moved underneath somebody.


APPENDIX — build history moved out of main.ts

---- v1.1 (27 Jul 2026, late) — five upgrades from the pre-deploy live review ------------------- Live verification against the real account (verify-live-reads.py, read-only) found that the discovery tag "nurture - puppy" matches ZERO contacts — no spelling variant matches anyone, and the newest 15 real New Lead contacts carry only "messenger"/campaign tags. There is no automatic puppy-vs-adult signal in the account's data model (age lives only in Ava's chat transcripts). So:

  1. ENROLLMENT IS HUMAN-GATED, by design not accident: applying the source tag to a contact IS the enrollment action ("tag them and the system takes over"). The New-Lead-stage requirement from spec §5's auto-discovery framing is deliberately DROPPED — under human gating the tag is explicit intent, and silently ignoring a deliberately-tagged contact because their opportunity sits in a different stage would read as "the system is broken." The safety guards that remain (already-customer, live-booking-already-exists, DND via rule 1 on first pass) block enrollment outcomes that would be WRONG, not merely unusual. Tag is env-overridable (TAG_SOURCE_SIGNAL).
  2. ENROLLMENT SURGE HOLD: normal operation is a few tags a day. If one run finds more than ENROLL_SURGE_THRESHOLD (default 25) un-enrolled tag matches, someone bulk-tagged — the run enrolls NOBODY, journals the hold loudly, and keeps holding until a human either removes the bulk tags or sets ACKNOWLEDGE_SURGE=true. Already-enrolled contacts keep processing normally during a hold. This is what protects a live account from a day-one accidental blast.
  3. SEND WINDOW: messages only go out 09:00–19:00 America/New_York (env: SEND_WINDOW_START_HOUR / SEND_WINDOW_END_HOUR), and SMS additionally never on Sunday (env SMS_SUNDAY_OK=true to allow; The Dogs Spot is closed Sundays — a business texting on its day off invites replies nobody answers). A due-but-out-of-window message simply waits for the next in-window run; offsets still anchor to enteredAt, so nothing else shifts. Spec line ~222 explicitly accepted the late-night case as a known limitation; this removes it.
  4. PAUSED CONTACTS GET A LIGHTWEIGHT PASS (v1.2 revision of v1.1's full skip): spec defines no resume path from paused_reply (a human reply permanently hands the conversation to a human — writing sheet §4's own philosophy). v1.1 skipped paused contacts entirely; the adversarial review showed that also disabled the DND/disqualified/became-customer rules for them, so a paused contact who later bought was never counted or retired. v1.2: paused contacts get a 2-read safety pass (contact + opportunities; reply check pinned failed-closed so a send is structurally impossible) — hard exits still fire, sends never do. Un-pausing is a deliberate human act: set the contact's status back to "main" in the puppy_state blob. Do NOT delete the entry — the puppy-path-v1 tag blocks re-discovery, so deletion just orphans them.
  5. ONE CALENDAR FETCH PER RUN, not per contact: the Pup Talk calendar holds 143 events in the ±90d window (measured live) and the old shape re-downloaded all of them for every active contact every run. Now fetched once per run and indexed by contactId. Plus: puppy_journal (append-only, capped 2000 rows — enrollments, sends, exits, pauses, holds, failures) and puppy_summary (per-status counts, sends by step, exits by reason, booked-during- path, customers-during-path) — the numbers Kim's 30-day report needs, accumulated from day one, readable by the watchdog. State hygiene: terminal entries untouched for 60+ days are pruned from puppy_state (their history stays in puppy_journal; the puppy-path-v1 tag still prevents re-enrollment).

APPENDIX — long inline notes moved out of main.ts

PUPPY PATH ENGINE v1.1 — nurture automation for The Dogs Spot's puppy leads (built 27 Jul 2026; v1.1 same evening — see the v1.1 addendum block below for what changed and why)

WHY THIS EXISTS AS A VAL, NOT A NATIVE GHL WORKFLOW: GHL's public API cannot create or edit workflow steps — UI-only, no way around it — and the workflow-builder UI itself was tested this session via browser automation and found completely unresponsive (clicks/scroll/keyboard, across multiple tabs and domains — not a one-off glitch). So this is a val.town script driven by the GHL REST API instead, matching this account's own existing idiom. Follows PupsClub-AutoSend-val-v2.1.ts's conventions closely on purpose: same j() fetch helper, same Deno.env.get-driven TEST_MODE-defaults-true pattern, same blob-based state, same wrapped-in-try/catch-writes-a-state-blob-every-run structure, same per-recipient try/catch inside the send loop.

HOW IT WORKS: One state blob (puppy_state), keyed by contactId, tracks each enrolled contact's position in either the 8-message main sequence or one of two post-Pup-Talk branches (A: no-show, B: had the call, didn't buy). Each cron run walks every contact currently in that state (plus, outside TEST_MODE, discovers new puppy-tagged leads to enroll) and re-evaluates a FIXED priority list of exit/branch rules (spec §5, rules 1-10 below, plus two additions flagged where they occur) top to bottom — first match wins. Only if nothing else matches does it fall through to "send whatever's next due." Every safety exit (DND, disqualified, became a customer, human reply pending) is checked BEFORE a send is ever considered, every single run, for every contact, regardless of what status they were in a moment ago — that ordering is the whole safety model.

HARD SAFETY INVARIANT (non-negotiable — Amir's written promise to Kim: "nothing gets switched on in the meantime"): TEST_MODE defaults true. When true, this script never looks at a real contact at all — discovery is skipped outright, and the only contact it ever touches is one fixed dummy resolved via contacts/upsert (firstName "ZZTEST", lastName "PuppyPath-Dummy", email aizhigitovamir@gmail.com — Amir's own inbox, safe to actually deliver to). Going live is a deliberate later step someone takes on purpose (flip TEST_MODE=false as an env var) — not something that happens by forgetting a flag.

GUARDRAILS:

  • "[TEST] " prefix on every subject line / SMS body while TEST_MODE, so a real send during testing is unmistakable — matches the PupsClub val's own convention exactly. Deliberately kept OUT of the email body itself so the client-approved copy stays byte-for-byte verbatim; the subject prefix alone is enough to make a test send unmistakable without touching approved wording.
  • Per-contact try/catch inside the main loop (matches the PupsClub val's per-recipient try/catch) — one contact's transient API failure doesn't block the rest of that run's contacts.
  • State is written AFTER a successful send, not before — deliberately the opposite of the PupsClub val's "write state before sending" choice, because the failure mode being defended against is different here. That script defends against a duplicate blast to many recipients from an overlapping run (one session opening, many people to text). This one processes contacts one at a time in a single sequential loop, so the real risk is a step being marked "sent" and then never actually going out because the network call failed. A duplicate nurture message on a rare retry is a minor annoyance; a silently-skipped one is a message someone was promised and never got.
  • Exit/branch priority order is fixed and total (rules 1-10 below) — every contact is walked through the SAME order every run, so DND / disqualified / became-a-customer / human-reply always outrank a scheduled send, never the other way round.

TWO GENUINE GAPS FOUND WHILE BUILDING THIS, NOT WORKED AROUND, FLAGGED HERE ON PURPOSE:

  1. GET /conversations/search — the call rule 5 (inbound-reply detection) needs — returns 401 "The token is not authorized for this scope" on this account's current PIT token. Tested directly this session: with and without contactId, on both 2021-07-28 and 2021-04-15 API versions, all four combinations 401 identically. This means reply-pause (arguably the single most safety-critical rule in the whole spec) cannot actually run live with today's credential. getReplyStatus() below fails CLOSED on this — it does not fabricate a "no reply" result, it reports conversationCheckFailed=true, and decideAction() treats that as "do not send anything to this contact this run," full stop (see rule 5 below). Practical consequence: AS CURRENTLY CREDENTIALED, this engine cannot send anything at all outside TEST_MODE, because the reply-check always fails closed for every contact — which is an extra, accidental safety margin on top of the TEST_MODE flag, but it is a real gap that needs a broader-scoped PIT token before this is actually USEFUL live, not just safe. Side note: inspect-dummy.sh's own conversations/search call would silently render this exact 401 as "(no conversation)" — its error handling can't tell "genuinely nothing sent" apart from "the API call itself failed." Worth fixing there too; flagged here since it surfaced while building this.
  2. The Pup Talk "declined / not now" outcome signal (rule 8) doesn't exist yet as its own thing. Per spec, this should key off the TDS-Declined tag being built for the separate front-desk task-layer project (BUILD-CHECKLIST-UI.md). That tag did not exist as of the 26 Jul build checklist ("Settings -> Tags has no existing TDS-Bought, TDS-Declined, TDS-Nurture"). Checked again this session via contacts/search filtered by tag (contains_set operator, confirmed correct via the account's own validation-error message listing valid operators) — zero contacts carry it, but that is a weak signal: a tag can exist in Settings -> Tags with nobody on it yet, and this token cannot read Settings -> Tags directly (401, same scope gap as #1) to check for real. Treated as NOT CONFIRMED LIVE. PUP_TALK_DECLINED_TAG below is a clearly-named constant, not invented behavior — wire the real value in once confirmed rather than guessing.

State blobs: puppy_state (per-contact, keyed by contactId) · puppy_run_state (per-run report, mirrors pups2_state in the PupsClub val) · puppy_last_error (same pattern) · puppy_journal (append-only event history, v1.1) · puppy_summary (aggregate stats rebuilt every run, v1.1).

Build history (v1.1 addenda, v1.2 hardening, the two gaps found while building) moved to NOTES-v1.4.md when this file hit val.town's 80,000-character ceiling. Nothing was deleted.


---- v1.4 (2 Aug) — OPT-OUT FURNITURE, appended at render time, never written into the copy ---- The adversarial pass checked the shipped templates and found the thing everyone had assumed was there: not one of the five SMS says STOP, and not one of the seven emails carries an unsubscribe line. An earlier risk register claimed opt-out wording was present. It was not. Retracted.

This is appended by the renderer rather than edited into puppy-messages.ts on purpose, for three reasons. It keeps the client-approved wording byte-for-byte, so what Kim signs off is what she wrote. It cannot be forgotten on a new path — every message rendered by this engine carries it, including the five paths whose copy has not been written yet. And it is one line to change if the wording ever needs to.

The SMS line is deliberately short: at 39 characters it costs a fraction of a segment, and the 320-char ceiling is checked against the FINAL rendered body, furniture included, so a message can never be pushed over the limit by the thing that makes it compliant.


Rule 4b — booked something else entirely (Free Training Day). NOT an explicit numbered rule in spec §5 (which only names Customer/Won-Paid/monetaryValue for rule 4), but the WRITING SHEET's §4 exits are unambiguous: "If they book anything else... A paid service, a Free Training Day, walking in and booking at the desk - any of it ends this sequence." Free Training Day is its own pipeline stage with no monetary value (it's free), so spec's literal rule 4 wording doesn't actually catch it — a real gap between the spec and the writing sheet's own stated intent, not a content disagreement (spec says content loses to the writing sheet, mechanism wins to the spec; this is neither — it's a rule the spec's enumeration simply left out). Resolved here rather than left broken. This also happens to be the only sensible use for the "done_booked" terminal status the state type declares but no other rule in §5 ever assigns.


Rule 6b (v1.2) — REBOOKED while in a branch. Found independently by the adversarial review and the fuzz round (concrete trace: contact no-shows -> branch_a -> books a new Pup Talk two weeks out -> v1.1 still sent branch_a-2 "Let's find another time" for an appointment they'd already replaced, then went terminal and stopped watching the new appointment entirely). If a live booking exists whose start time is NEWER than the branch anchor (i.e., it's a different, later appointment than the one that put them in the branch), the branch conversation is obsolete: go back to awaiting_appt_outcome and track the new appointment's outcome like any other. Rules 7/8 then re-fire off the NEW appointment — including a second no-show correctly re-entering branch_a with the new anchor.


---- v1.3 (2 Aug 2026) — R19: THE ENROLMENT-SCAN QUOTA FIX ------------------------------------- Raised by the adversarial pre-mortem and NOT refuted. The enrolment loop called getOpenOpportunities() once per candidate. With one or two human-tagged candidates that is nothing; the day the dormant backlog is released it is one API call per candidate per run, on top of the walk itself, and GHL answers sustained overload with 401/403 rather than 429 — so the engine would not merely slow down, it would look credential-broken while quietly starving.

The fix is the same shape as the calendar index directly below, which already solved this problem once for appointments: fetch the exit-triggering stages ONCE per run and index them by contactId. A candidate absent from the index has no disqualifying opportunity, which is the only question enrolment eligibility actually asks. Cost goes from O(candidates) to O(exit-stage size) — and those stages are small, because the pipeline's own census puts Customer / Won-Paid / Onboarding at zero and Free Training Day and Disqualified in the low hundreds.

Deliberately NOT applied to already-enrolled contacts: their per-contact read also has to catch a monetaryValue > 0 opportunity sitting in any stage at all, and that population is bounded by the surge guard, so it was never the blowout. Precision beats a clever sweep here.


Field name NOT independently proven this session (the 401 blocked confirming it) — trying the conventional GHL field defensively. v1.2 tightened two ways after the adversarial round:

  1. STRICT direction validation — only the literal strings "inbound"/"outbound" count as a readable signal. The old direction == null check let a falsy-but-not-nullish value (e.g. an empty string on a malformed conversation record) slip through as "confirmed no reply" — the exact unrecognized-shape case fail-closed exists to catch.
  2. TEMPORAL scoping — an inbound message that predates this contact's enrollment is a historic conversation someone already handled (e.g. a "do you do daycare?" text from months ago), not a reply to this sequence. Without this, such contacts would pause permanently before their first message ever sent. If no usable timestamp field exists on the record, we conservatively treat the inbound as blocking (pause) rather than ignoring it. Timestamp field names are best-effort until the scope unblocks and the real shape can be confirmed live.

v1.2 — paused_reply contacts get a LIGHTWEIGHT safety pass instead of v1.1's full skip. The adversarial round showed the full skip quietly disabled rules 1/3/4 for paused contacts: someone who replied and later BOUGHT stayed "paused_reply" forever, was never counted as a customer, and never aged into pruning. Now: two cheap reads (contact + opportunities), no reply/no-calendar calls, conversationCheckFailed pinned true so rule 5 structurally blocks any send — only the hard-exit rules can fire. Un-pausing for real remains a human act: set the contact's status field back to "main" in the puppy_state blob (do NOT delete the entry — the puppy-path-v1 tag blocks re-discovery, so a deleted entry just orphans them; header docs updated accordingly).


---- booking-link shortener (spec §7) --------------------------------------------------------- Purpose: message copy references one short, stable link; if the real booking URL ever changes, one line changes (REAL_BOOKING_URL above) instead of eight message templates.

Deployment note, honestly flagged rather than guessed at: a single val.town val has one default export, which is this file's cron() entry point. Whether val.town's current UI can attach a second, HTTP trigger to a NAMED export (not the default) on the SAME val was not verified this session — VT_TOKEN was unavailable, so nothing could be deployed to check. If it can, wire this function up directly. If not, the fallback is a second, tiny val: two lines, importing REAL_BOOKING_URL from this file and re-exporting a redirect as ITS default export. Either way this is a convenience, not a critical system, exactly as spec §7 frames it — kept deliberately simple.

v1.7 — SLOW TRACK (TAPER), deployed 4 Aug 2026

Kim's spec verbatim (3 Aug, 8:11 PM): "a text or email once a week for month then every two weeks for 6 months then every month moving forward." Implemented as a fifth sequence:

  • New status slow_track (non-terminal). Entry: rule 9's 21-day completion now hands over instead of parking in done_soft_21d. done_soft_21d kept terminal for legacy blobs — no retroactive enrollment, deliberately.
  • Copy + cadence in taper-messages.ts: SLOW_WEEKLY x4 (rel d7/14/21/28), SLOW_FORTNIGHTLY x13 (rel d42..210), SLOW_MONTHLY_BANK x12 keyed by CALENDAR MONTH of the send (July=heat+fireworks, Dec=holidays). nextSlowTouch() is the one scheduler; slowTemplateAt() shared by decideAction and the executor so decided and sent templates cannot drift.
  • HARD PROMISE, test-enforced: zero booking links, zero asks in all 29. The only CTA anywhere is "reply and a person reads it."
  • All rails hold mid-taper (proven in tests/slow-track.test.mjs, 28 checks): reply -> paused, booking -> awaiting/branch (never back to slow), STOP/dnd -> hard exit, customer -> exit, R27 foreign quiet, R28 per-channel consent (skip-consume), min-gap, send window.
  • HARDENING found by the suite: min-gap now ignores a future-dated lastSentAt (mirrors R27's since>=0 guard). Previously a corrupted/restored blob with future lastSentAt froze the contact until that date.
  • Panel: slow_track in labels/stage bar/ACTIVE, plus a "What needs doing today" card answering Kim's 8:20 PM question in one computed sentence.
  • Deploy: val.town API PUT (main v22, messages v21, api v23, taper-messages created 201). All four hash-verified byte-exact after upload. Live run verified: report v "1.7.0", ok:true, testMode:true. main.ts is 79,537 bytes — 463 under the 80k ceiling; next addition must split.

The dry tagging pass (v1.9, 5 August 2026)

main.ts calls dryScan() from puppy-detect.ts once per run, before the TEST_MODE branch, and writes the result to the tag_candidates blob. It applies nothing.

Why it exists. The engine enrolls people carrying nurture - puppy. All 5,544 contacts were checked with a server-side tag filter: zero carry it, and nothing in the account applies it. So real enrollment finds nobody and will keep finding nobody until the tagging is built. This pass is that tagging, running with its hands tied: it works out who would be picked up, writes the counts down, and lets a human look before anything is switched on.

Why it cannot tag anybody. puppy-detect.ts contains no fetch, no client, no POST and no blob write. That is structural rather than a promise, and tests/detect.test.mjs N13 asserts it, with a negative control that adds a fetch call and demands N13 go red.

Why it sits ABOVE the TEST_MODE branch. The first version was inside the else, which is the not-practice-mode path. The system is in practice mode, so the line never once executed. It deployed cleanly, verified by hash, and passed every test, and it was dead — nothing checked that the code had actually run. Three deploys later it was caught by loading the page and finding the paragraph missing. A hash match proves the bytes arrived. It proves nothing about whether they run.

What it stores. Contact ids only, plus counts. No names, no dog names, nothing anybody wrote. Asserted by N15.

Staleness. scanAgeNote() puts a sentence on the page once the reading is over six hours old and a blunter one past two days, so a pass that quietly stopped cannot keep showing yesterday's numbers as this morning's.

The open question for Kim. A dog of about a year sits exactly on the boundary and there are a lot of them. The slow track covers the teenage stage, so they may well belong. PUPPY_MAX_MONTHS is 12 and it is her call, not ours.

deposit refresh — why it hangs off the engine's interval (6 Aug 2026)

deposits-cron.ts is registered on this val as a SECOND file of type interval. The val accepted it and the API reports the type back correctly. It has never been observed firing on its own.

What was actually watched, at named timestamps:

  • 15:44:27Z — deposits-cron.ts deployed. 15:44:33Z — the holiday_deposits blob advanced. Six seconds after a deploy is a deploy-triggered first run, not evidence of a schedule.
  • 17:21:23Z → 17:29:40Z — no movement.
  • 18:24:13Z → 18:32:38Z — no movement.

val.town's API exposes no schedule field for a file, so there is no way to read the cadence back; the only proof available is the stored at advancing with nobody triggering it, and that has not been seen. So the refresh is hung off main.ts's interval, which IS watched keeping time — the dashboard grades its lateness and the watchdog alarms on it.

Shape of the call, and why each part is there:

  • LAST in the run, after the run lock is released and after every send and state write, so a slow or failing CRM read cannot delay or break the follow-up work.
  • Gated on the stored blob being older than an hour, so it is a couple of extra requests per hour rather than per tick.
  • Wrapped in try/catch that logs and swallows. A failed refresh degrades into an older "last checked" timestamp on Kim's page, which the card itself warns about past 36 hours.
  • runCollection() is read-only by construction: one fetch, method: "GET", and a test asserts the collector contains no write verb.

deposits-cron.ts is left in place. If val.town does start firing it, the hourly gate means the two simply share the work rather than duplicating it.

kill switch — the full note (moved out of main.ts, 6 Aug 2026)

Checked before the run lock, before state loads, before a single contact is looked at. Kim can set it herself from the control panel (api.ts) with two taps and no help from anyone; it is the one control she must never have to ask for.

It deliberately fails OPEN: if the blob read throws, the engine carries on rather than halting on a storage hiccup, because a silent permanent stop would be indistinguishable from the outage this whole system is built to be visible about.

Nothing is deleted while stopped. Every contact keeps its exact position and resumes from it.

deposit refresh — WATCHED WORKING, 6 Aug 21:49Z

The note above said deposits-cron.ts had never been observed firing and the refresh was therefore hung off main.ts's interval. That was true when it was written and is now superseded by evidence.

Observed, with the source recorded by the code itself:

  • 2026-08-06T20:49:03.612Z — the list refreshed with nobody triggering it. First autonomous run ever seen here. Unlabelled: the source stamp did not exist yet.
  • 2026-08-06T21:49:13.781Z — refreshed again, lastAttemptBy: "deposits-cron".

Exactly one hour apart, which is also the watchdog val's observed cadence (its own history runs 19:02, 20:02, 21:02...). So val.town DOES run a second interval file in a val, on an hourly schedule, and the earlier conclusion was a statement about observation rather than about the platform.

Both paths are wired and both go through refreshIfDue, which reads the same ATTEMPT_KEY stamp, so whichever fires first closes the gate for an hour and the other is a no-op. Nothing needs removing.

How to check it without breaking it: GET the deposits endpoint WITHOUT ?run=1. That returns lastAttemptAt, lastAttemptBy and refreshDueAt, and does not write the stamp. A ?run=1 DOES write it and pushes the gate an hour out — every observation on the evening of 6 August was ruined that way before the read route existed.

readChannelConsent read status !== "active" as blocked. That is exactly backwards.

GoHighLevel names dndSettings after the DO NOT DISTURB switch, not after the person's reachability. So the status describes the switch:

statusmeaningmust
activethe DND is ONBLOCK
inactivethe DND is OFFSEND
permanentSTOP reply from the recipientBLOCK
absent or ""no per-channel recordSEND

Verified against 2,500 live contacts on 10 August 2026. The three shapes are unambiguous:

  • active, 55 contacts on SMS, every one carrying TWILIO_ERROR_CODE: 30006, a landline or unreachable carrier, with top-level dnd:false so nothing else would have caught them. Plus every channel on the contacts a workflow put on DND (Updated from workflow_c6fc2df4...), all carrying dnd:true. The old code read all of these as PERMISSION TO SEND.
  • permanent, 25 contacts across SMS and RCS, all carrying STOP_KEYWORD. Real human opt-outs. The old code blocked these correctly, by accident.
  • inactive — Kristina Gant, dnd:false, Email, SMS and Call all inactive, message Updated by contact merge. She is contactable, and on 10 August she was the only contact carrying the nurture - puppy tag. The old code refused her.

What would have happened at go live. The single enrollee would have been silently skipped on every channel, all eight main steps consumed as skips inside a day, and the run would have reported enrolled:1, sent:0, failed:0 — which is indistinguishable from a clean launch. Meanwhile 55 contacts with an SMS block and no top-level DND would have been texted.

Why 394 assertions and 203 negative controls missed it. The only test that touched this function, I3, read this file as a STRING and asserted the literal "active" appeared somewhere inside it. It never called the function. A source-grep cannot detect an inversion; it passes just as happily on the wrong comparison as the right one.

The fix, and the shape of the fix. The predicate now allow-lists the two states that mean "go" ("" and inactive) rather than deny-listing the states that mean "stop". An unrecognised status therefore fails CLOSED. If GoHighLevel invents a fourth state tomorrow, this blocks rather than sends.

readChannelConsent is now exported, and I3 has been replaced by I3, I3b and I3c which CALL it with the three live shapes. Fault injection confirms it: restoring the old comparison turns I3 and I3b red, and the fix turns them green.

The general lesson, and it is the most useful one in this file. A test that inspects source text is not a test of behaviour. Any function that decides whether a real person receives a message must be reachable from a test and must be called by one.

R32 (10 Aug 2026) — the weekly report counted Facebook leads off a column that is null on 78% of contacts

report.ts built its source table and its ad reconciliation from contact.source. Live, that field is null on 78.2% of contacts over a week and 81.3% over a month, and it has never once carried the string "facebook". So the report told Kim "the CRM recorded 0 contacts tagged Facebook/Instagram/Messenger" in a week where her own attribution records showed 116.

The fix already existed in the tree and was not wired: report-logic.ts has had channelBreakdown, attributionCoverage and prettyMedium since 4 August, and dashboard.ts has read them correctly that whole time. Only the weekly report never got it.

Now: report.ts reads the first attribution entry per contact, the source table uses channelBreakdown, and the reconciliation uses a new exported facebookLanded() so a test can reach it — the count used to be computed inline inside a template literal, which is why nothing caught it. PAID_SOCIAL_RE is case-insensitive on purpose: channelBreakdown returns display labels ("Facebook"), so porting the old case-sensitive regex would have reproduced "0 Facebook contacts" exactly, wearing a new hat.

unsourced is deliberately no longer added into the Facebook figure. Its justification — "Messenger contacts usually arrive unsourced" — was an artifact of reading the blank column. Under attribution the unattributed population is about 1 in 142, not 111, so adding it would be padding a number that no longer needs padding. It is reported separately as a data-quality note.

Live after deploy: "151 of the 175 new contacts in your CRM arrived from Facebook or Instagram", and the source table reads Facebook 140, Instagram 11.

R33 (10 Aug 2026) — go-live hardening

  • fetchPupTalkApptIndex() was unwrapped and runs before the TEST_MODE branch, so one 401 from the calendar endpoint aborted the entire run before a single contact was examined. It has already done so once, stored as lastError on 2026-07-30. Now caught: an empty index degrades the Pup Talk check, which is survivable, where an exception stopped everything, which is not.
  • api.ts told Kim three times that things take effect "within 15 minutes". The interval is hourly. Corrected to "within the hour", including the sentence about honouring Do Not Disturb, which is the one that matters most.

R34 (10 Aug 2026) — the money matcher, scoped and summed

  • classify() had no stage filter. It took whatever the caller handed it, and a contact appears on up to eight boards here. Unscoped, live, $4,020 of $4,685 lands on a card that is not a booking: $500 onto a Survey Requested card for a customer whose real booking already carried $2,395, $2,550 onto the review pipeline, $900 onto a PupTalk card. The exception list goes 0 to 10, which breaks the promise on Kim's page that nobody has to do anything. VALUABLE_STAGES is now a frozen constant and the filter is inside classify(), because a caller that hands over every board is the normal mistake. The fixture in money-match.test.mjs used a stage NAME where live data carries an ID. Adding the filter turned nine tests red until it was corrected. MM-t now asserts every entry is a 36 character id, because matching on a name would make this filter a silent no-op against production.
  • money-match.ts:76 overwrote instead of summing. Two payments toward one booking produced two writes and one silently won. Live: Laurie Rubnitz, $120.00 and $90.00, both resolving to 7yES6q2kajVTQIK6tEx3. planWrites() now groups by opportunity and writes the sum once, carrying the payment ids that made it up. Summing PRESERVES the total, so the $174,115.00 on Kim's page was always the correct figure and it was the code that had to catch up. The old bug self-sealed: once a wrong value was written the next run returned ALREADY_VALUED, so the lost money was unrecoverable without a manual audit.
  • valuedAfter.count counted payments where it should count bookings.

R35 (10 Aug 2026) — the ingestion layer that did not exist

money-match.ts was imported by its own test and by nothing else. There was no call to /payments/transactions anywhere in engine/, scripts/ or build/, so every figure on Kim's money page came from a process that was not in the repository, and the filters below were unwritten rather than misconfigured.

engine/payments-source.ts is that layer. Separate module on purpose: MM-h asserts fetch( never appears in money-match.ts.

  • Accepts all five live source types including payment_link, which moves $0 today and would have reported the front desk's first day of takings as zero the moment the reusable links go live.
  • An unrecognised source type is kept and reported, never dropped.
  • Filters on AMOUNT, not on source type. 171 live rows are $0 booking artifacts, all status succeeded; 10 are sub-$1 card tests, all pending. Filtering entitySourceType !== "calendar" is the near miss: it leaves 13 zero rows standing, which is what invents a phantom payment collision.

R36 (10 Aug 2026) — copy that expires

Nine strings in worklog.ts and decisions.ts said "this evening", "this morning", "last night". The page told Kim "this evening" every day forever. This was her oldest open item, promised as Monday's first job on 8 August and recorded as "never reproduced" for two days because nobody looked in the copy modules. All nine now read "that evening", "that morning", "the night before". KC-dates is a lint test that fails if any of the banned phrases returns to a copy module.

R37 (11 Aug 2026) — go-live prep: no-email guard, visible deferrals, state prune

  • channelBar(): one answer to "may this channel be used". Two causes kept separate because the journal must name WHICH: consent (dndSettings, R31) and ABSENCE (no email on the record). Logging absence as an opt-out misinforms the reader. Live driver: 2 of the 3 tagged contacts carry a phone and no email, and main-1 is an email; unguarded, that send 4xxs and retries hourly forever. Phone absence is not handled because every tagged contact carries a phone. Both polarities proven by calling it: tests/consent-guard.test.mjs CG-4/CG-5; fault injection goes red.
  • Deferral visibility: skip reasons were computed and discarded, so go-live day could not tell waiting from broken. Informative reasons now hit out.log and the journal (kind "deferred"); bare not-due stays silent, skippedNotDue counts it.
  • api.ts ?prune=: admin-gated removal of one contact from puppy_state. Exists for one job: the ZZTEST dummy out before TEST_MODE flips.
  • Process lesson (LESSONS.md #10): the first version of these tests landed in tests/harness.mjs, which run-all EXCLUDES as scaffolding — the most important tests of the week were running nowhere, and a stale E4 in that file had been red for days unseen. Tests now live in a discovered *.test.mjs; after adding a test, watch the suite count move.

R38 (11 Aug 2026) — the mode is a blob, because the env UI is a login away

TEST_MODE was only settable in val.town's environment UI, and at go-live time that browser session was logged out with nobody available to log in. Ten-routes rule applied: the v2 API has no environment endpoints (404 on every guess), tokens are val-file-scoped (403 on /v1/me and /v1/blob), and typing a password is out of bounds. So the mode moved to something a session can always reach:

  • resolveMode(envVal, blobVal) is pure and exported; all eight combinations tested (CG-7). Env var, if set, WINS, so the documented knob still works. Only boolean true in puppy_mode.live arms live; a string "true", a missing blob, a malformed blob are all practice.
  • cron() re-resolves at the very top of every pass, so one pass has one mode.
  • api.ts ?arm=live|practice (admin-gated) writes the blob and reads it back as the receipt.
  • Rollback gains a second lever: ?arm=practice at next pass; the kill switch stays the instant one.

R39 (14 Aug 2026) — comment blocks moved out of main.ts to make room for the reachability fixes

main.ts was at 79,184 of the platform's hard 80,000-byte ceiling. Four narrative comment blocks were moved here VERBATIM so the safety work below could land; each site keeps a one-line pointer. Nothing was edited, only relocated. (scripts/deploy.mjs prints this exact instruction when the file goes over: "Move comments to NOTES-v1.4.md.")

From getBlobModule()

// Dynamic (not static like the PupsClub val) so this file's pure logic can be imported under Node +
// tsx for local testing without resolving esm.town or needing a Deno runtime — under real
// val.town/Deno execution this resolves identically, just on first call instead of at module load.

From sendEmail()

// Body shape verified live this session (27 Jul 2026, against the sanctioned TEST_MODE dummy contact
// / aizhigitovamir@gmail.com): {"type":"Email","contactId","subject","html"} on Version 2021-04-15
// (same API version as the proven SMS call) returned HTTP 201, {"msg":"Email queued successfully.",
// "messageId":..., "emailMessageId":...}. The spec's own best-guess shape worked on the first
// attempt — nothing needed adjusting.

Note added 14 Aug: that response is the same emailMessageId the delivery reconciliation in reachability.ts now reads back from GET /conversations/messages/email/{emailMessageId}. It was documented in this comment from day one and discarded by the code for three weeks.

From addTag()

// POST /contacts/{id}/tags is additive — verified live this session against the dummy contact (an
// existing tag survived the call, response's tagsAdded confirmed the new one). Deliberately NOT using
// /contacts/upsert with a tags array for this — upsert's tag merge-vs-replace behavior was never
// tested and was not worth risking on a real contact's existing tag set.

From createTask()

// Shape verified live this session against the dummy contact (HTTP 201, then confirmed via the
// proven GET tasks read). assignedTo deliberately omitted rather than sent as JSON null — no
// confirmed GHL user id for "the hire" exists yet in this engine's scope (same open gap
// BUILD-CHECKLIST-UI.md already flags for its own tasks) — omitting the key tested clean.

From applyDecision(), the advance_status case

// v1.2 — entering a branch always starts that branch's 2-message pass from the top. Matters for
// the rule-6b re-entry paths (branch -> awaiting -> branch again for a NEW appointment): the
// shared lastBranchStepSent counter would otherwise start a second branch pass mid-sequence
// (or, worse, route a first branch_b pass to B2 because branch_a had already sent A1). A
// repeated branch message only ever happens when a genuinely new no-show/decline occurred, so
// the copy stays contextually true.

From applyDecision(), its preamble

// Pure: given a Decision (already known to have succeeded, or to need no IO at all) and the current
// state, returns the resulting state fields. No IO. This is the ONE place PuppyState transitions are
// written, used by both cron() (after a successful send/task, or immediately for decisions with no
// gating IO) and the local test harness (to simulate a full multi-day run without live network calls)
// — kept as a single function specifically so the tested behavior and the shipped behavior cannot
// silently drift apart from each other.

v1.10 (14 Aug 2026) — the wiring, the dead letters that could not come back, and the refusals that repeated

Six defects found by an adversarial pass over the v1.9 tree. Recorded here because five of the six are one-line call sites in main.ts and the reasoning does not fit next to them at 80,000 characters.

the wiring

Five of v1.9's headline fixes could be DELETED from main.ts and the whole suite stayed 25/563/0 GREEN: noPhone at both Facts sites, optOutRequested, the honourOptOut call, and the onDeliveryStatus call. The helpers in reachability.ts were tested thoroughly; the wiring from cron() into them was tested by nothing at all. tests/wiring.test.mjs now drives the real exported cron() against a fake fetch, a fake clock and a fake blob store, and asserts the observable outcome — an SMS that is never POSTed, a DND PUT that is, a reconciliation read that happens. Each of those call sites was deleted in a scratch copy of the tree and the suite confirmed RED before this was written down.

enrol refusals: which are terminal and which are not

enroll_refused wrote a journal row and no state entry, so the same contact was refused on every run forever — 39 of the last 40 rows on Kim's live activity page were the identical sentence, hourly, for two days, and at ~24-30 rows a day the 2,000-row journal cap evicts all real history in about two months. enrolmentBar's branch already had this right and said why: the terminal entry, NOT the tag, is what stops this refusal repeating every run. The same reasoning was not applied one line up.

The split is on whether the refusal can stop being true on its own:

  • already a customer — TERMINAL, as done_not_enrolled. Rule 4 hard-exits a customer on the first pass anyway, so enrolling them could only ever produce an immediate exit. NOT done_customer, which was the first attempt and was wrong: api.ts:157 computes the WON tile as byStatus.done_booked + byStatus.done_customer, off the saved state. Somebody who was ALREADY a customer before the path touched them would have been added to the one number the client judges the system by — a sale this engine had nothing to do with, appearing on her page as one it won. A new terminal status says what actually happened instead, is counted in neither ACTIVE nor WON, and api.ts degrades safely on an unknown status (it renders a true sentence and reports the missing label back to us in unlabelledStatuses). It also cannot be resurrected by a re-tag, which is correct: a customer is not a lead. The residual cost is stated plainly — if Kim ever needs to nurture a lapsed customer she tagged while they were still a customer, a tag will not do it. The alternative was leaving them stateless and journalling once, like the live booking, and that was rejected because a permanent candidate jams the enrolment SURGE guard: 40 of them sitting in rawCandidates on every run exceeds the threshold of 25 and holds enrolment for everybody, forever.
  • live booking already exists — TRANSIENT. The booking may be cancelled tomorrow, and then they SHOULD enrol. So it must keep being re-evaluated, and it must NOT get a state entry — any state entry at all removes the contact from the candidate filter, which is the very bug being fixed. The repetition is stopped at the journal instead, by refusedBefore. The log line still prints every run (it is per-run output, not the 2,000-row history).

the dead letter that could never come back

The candidate filter was !tags.includes(TAG_ENROLLED) && !state[c.id]. A dead-lettered contact sits in state untouched — the per-contact loop continues on any terminal status — so lastCheckedAt never moves and pruneState only drops it after 60 days. Kim removing the tag, fixing the number and re-tagging produced zero candidates, which is exactly what the dead-letter task told her to do. Fixed by reEnrollable (reachability.ts), which is deliberately narrow twice over: only the two dead-letter statuses, and only when the contact now has an email address or a phone number. A contact who is still unreachable is not a candidate at all — nothing was fixed, so re-admitting them would re-refuse them every run, which is the journal-eating defect above wearing a different hat. done_send_failed cannot be verified that way (a bad number looks exactly like a good one), so it is re-admitted with a fresh attempt budget; re-enrolment applies TAG_ENROLLED, and that is what stops it repeating.

the paused STOP

The lightweight paused-reply pass pinned conversationCheckFailed: true and never set optOutRequested, so a paused contact who later texted STOP was never detected: no DND write, and they were released 14 days later as done_hard_exit for the wrong reason. Rule 1b outranks rule 5, so setting optOutRequested on that pass reaches the hard exit while conversationCheckFailed still guarantees no send can happen on it. It costs one conversation read on a population that is small by definition. honourOptOut is called on that path too — the DND write is the whole point, and it is what silences Chloe, Ava and the 165 workflows as well.

From cron()'s Node/tsx shim

Val.town/Deno provides a global Deno; this file is normally only ever loaded there. No Deno runtime was available in the build/test session, so the local harness imports this exact file under Node. The shim defines a minimal stand-in ONLY when Deno does not already exist. Under real val.town execution Deno is already defined, the if is false, and the block never runs — zero effect on deployed behaviour.

From getReplyStatus(), the unreadCount arm (R21)

Chloe, Ava and 165 workflows all send. A customer replies, an automated outbound lands seconds later, the direction field flips, and the engine reads 'no reply'. unreadCount is the second signal: outbound does NOT clear it, so an unread thread means somebody wrote in and nobody looked — which must block a send whatever the newest message was.

From decideAction()'s preamble

Pure function: no network, no side effects. Every rule is evaluated in order for every call — "first match wins" per spec, so a later rule never overrides an earlier one, and the SAME order runs for every contact on every pass regardless of their current status. This is what the whole safety story rests on.