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.
---- 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":
---- 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.
---- 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.
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.
---- 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:
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:
TWO GENUINE GAPS FOUND WHILE BUILDING THIS, NOT WORKED AROUND, FLAGGED HERE ON PURPOSE:
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:
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.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.
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:
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.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.
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:
holiday_deposits blob advanced. Six
seconds after a deploy is a deploy-triggered first run, not evidence of a schedule.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:
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.
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.
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:
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:
| status | meaning | must |
|---|---|---|
active | the DND is ON | BLOCK |
inactive | the DND is OFF | SEND |
permanent | STOP reply from the recipient | BLOCK |
absent or "" | no per-channel record | SEND |
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.
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.
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.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.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.
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.entitySourceType !== "calendar" is
the near miss: it leaves 13 zero rows standing, which is what invents a phantom payment collision.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.
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.?arm=practice at next pass; the kill switch stays the instant one.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.")
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
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.
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.
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:
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.refusedBefore. The log line still prints every
run (it is per-run output, not the 2,000-row history).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 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.
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.
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.
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.