# ADR: The campaign sends, the model writes rows

Status: accepted in part, 2026-10-24: the rules at 1.0.4.58 (Decision 1), the database at 1.0.4.59 (Decision 2), the renderer and the shadow send at 1.0.4.60 (Decision 3), the inbox side at 1.0.4.61 (Decision 4), the lead run at 1.0.4.62 (Decision 5), the card and the alert at 1.0.4.63 (Decision 6). Filled as each rung of the campaign ships: the database, the renderer and the shadow send, the inbox side, the lead run, the card, the shadow week and the switch to on. Nothing sends while this record says shadow.

## Reading list

1. [ADR: One host per process, the rings under them](https://theyard.stevenstout.biz/?doc=adr-one-host-per-process-the-rings-under-them): the rings these rules live in.
2. [ADR: The tracker merger runs in Azure](https://theyard.stevenstout.biz/?doc=adr-the-tracker-merger-runs-in-azure): the Function the campaign joins, and the inbox pass its bounces and replies will ride on.
3. [ADR: Every Claude lane reports its usage](https://theyard.stevenstout.biz/?doc=adr-every-claude-lane-reports-its-usage): how the lead run's model cost reaches the usage card.

## In plain words

Steve asked for an email campaign to hiring managers that runs on its own. A model is good at reading a posting and finding the one plain thing a company does, and bad at being the same twice. So the work is split down the middle. A model writes rows: who the company is, who runs its engineering team, where the address was published, one clause saying what the company makes. Code does everything that touches the send: it checks every rule, fills the approved template word for word, picks the order, holds the count, and sends. No model is in the send path, and a template changes only when Steve says yes.

This first version is the rules, written as code with no database and no mail. Each one is a test that fails the build.

## Context

- Steve, 2026-10-24 12:22 CDT: "the emails should run from Azure functions and AI is only used to generate the leads", and "It sends automatically at 8:00 am". 12:30: seven days a week. 12:58, after the review: "Okay do all of that, but I'm not sure about the 3rd party for email", so there is no address finder: public sources with their URL only, no account, no key.
- The review of the plan (J:\mentor\REVIEW-EMAIL-CAMPAIGN-2026-10-24.md, findings F1 to F19 and its section 8) found what the plan missed. The do-not-apply list carries none of his five former employers (F1). A "per domain" 30-day rule misses a company under its parent and a company known only by name (F10). Template A said "I applied today" on a send the next morning (F8). Re-ranking every lead with a model every run reorders the list every run (F9). A {WhatTheyDo} written by a model can carry its voice into his email (F15). The live-thread rule had no shape in code (F16).
- The Function's Gmail token already holds gmail.modify, which can send (F4). The only things between a defect and his own inbox are the switch and the cap in code, so both are constants a test reads.

## Decision

1. **The rules are code in `functions/JobHunt.Domain/Features/Campaign/`, with no I/O, each held by a test that names the review's case.**
   - `FormerEmployers`: Storee Construction, Conway Investment Research (also Conway Investment Solutions), Cass Information Systems, Hunter Engineering and the State of Missouri, with their variants and domains (storee.com, conwayis.com, cassinfo.com, hunter.com, mo.gov). Matched by name the way Rule 50 matches names, or by domain key. No date: a former employer stays out for good.
   - `CompanyKey`: the registrable domain, public-suffix aware for the suffixes this search meets (mail.acme.co.uk is acme.co.uk, dor.mo.gov is mo.gov). An unknown suffix over-joins, which over-holds.
   - `ThirtyDayRule` and `CompanyGate`: one email a company in a rolling 30 days, follow-ups counted, matched by key, by parent key, or by name. A send on day 0 holds through day 30; day 31 is the first allowed. The gate reads a former employer first and names it, then the do-not-apply list, then the 30 days.
   - `LiveThreadRule`: a tracker row for the company in the last 30 days that is a person's thread (a LinkedIn message, an email, a call, a rejection) holds the lead with its Count. An application row, the ATS sweep's receipt and an unanswered connection request do not hold: the first two are the application itself, and the third is the LinkedIn order's input.
   - `Tells` and `WhatTheyDoRule`: S1_WORDS and S1_PHRASES of ai_tells_five_signs.py in one constant, matched against a checked-in copy of the Python file by count and content. The clause opens with a verb from a list of fifteen, runs 20 to 110 characters, ends without punctuation, has no comma clause longer than the phrase before it, no dash, quote marks, braces or address, never names the company, and carries no tell. A failing clause blocks its lead with the rule named.
   - `CampaignRules`: `DailyCap` 10, `Ramp` 3, 5, 10 on the first three days of `on`, five seconds between sends.
   - `RecipientDomainRule`: business domains only (no gmail.com, yahoo.com, outlook.com, hotmail.com, icloud.com and the rest of the list), and MX records checked and found.
   - `Strength`: a formula over stored facts (remote stance, .NET evidence rows, a posted role, the band's top against $155,000 and $170,000, the ghost score, days since first seen, a .NET posting in the last 30 days), the model's step held to plus or minus one, and a 16-digit hash of the inputs so the model is asked again only when a fact moves. A ghost score of 3 or more takes 25 points, more than the rest of the formula spans.
   - `AppliedWhen`: "today", "yesterday", "on Friday", "last Saturday" from AppliedAt and the send time in Central; Track A never past seven days.
   - `ProofKey`: reliability, platform, leadership and ai, each a clause built from a line of the 2026-10-24 resume. No template has a {Proof} line yet; adding one is a template change that waits for Steve.
   - `LinkedInOrder` and `SendOrder`: an accepted invite first, then an invite out more than three days, then the rest; then Strength, then the oldest lead.
   - `TemplateFill`: every {Variable} from the row, a required blank fills nothing and names the blank, and an optional line goes whole with the gap under it when any of its values is blank.

2. **The database is one Basic database, `sqldb-jobhunt-ss`, on the server the site already runs on, and the Function signs into it as itself.**
   - `infra/jobhunt-sql.bicep` makes it and nothing else: Basic, 5 DTU, 2 GB, local backup, on `sql-theyard-ss-westus3`, Incremental. Its own template so a deploy touches this one database. About $4.90 a month, fixed. The free serverless offer was turned down (the review, F2): its allowance is 55 awake hours a month, a campaign that wakes it three times a day spends that by the nineteenth, and then it either pauses with nothing failing loudly or bills by the hour.
   - `functions/JobHunt.Database` is the schema, the same pattern as the site's (ADR: Data first, and the database in source control): the `campaign` schema, `Companies` (the key, the parent key, the names, the remote stance, the newest .NET posting, the public board to scan, do-not-email), `CompanyEvidence` (one row per posting that asks for .NET), `Leads` (every template variable a column, the address with the URL it was published at, MX checked, AppliedAt, the LinkedIn dates, the proof key, the follow-up post, the ghost score, Strength with its model step, reason and inputs hash, Blocked, the tagged code and its five links), and `EmailsSent` (both bodies, the template and its version, the Gmail ids and the Message-Id a bounce quotes back, touch, shadow, bounced, replied). Three views for the Sheet mirror later: `LeadsMirror`, `SendsMirror`, `DailyCounts`. CI and the gate compile it to a DACPAC.
   - The Function's identity, `id-jobhunt-ss`, has a contained database user made from its SID, with `db_datareader` and `db_datawriter` on this database only, never on the site's. Made by script on 2026-10-24 (`CREATE USER [id-jobhunt-ss] WITH SID = 0xDD6CF11F4D186148BF294DCB9E7D89C0, TYPE = E`, the SID being the bytes of the identity's client id, the same rule that gives `id-theyard-ss` ADR-039's 0xCAA688281CBEA546A1DEC666B1D193E5) and read back from `sys.database_principals` at 18:38 CDT: `name=id-jobhunt-ss type_desc=EXTERNAL_USER sid_hex=0xDD6CF11F4D186148BF294DCB9E7D89C0`, roles `db_datareader` and `db_datawriter`. The schema was made from these files by the same script and read back: Companies 15 columns, CompanyEvidence 6, Leads 40, EmailsSent 22, three views.
   - The ports are in `JobHunt.Application/Features/Campaign/`: `ICompanyBook`, `ILeadStore`, `IEmailLog` and `IAddressFinder`. `SqlCampaignStore` in Infrastructure answers the first three over `Microsoft.Data.SqlClient` with `Authentication=Active Directory Managed Identity;User Id=<client id>` and no secret anywhere. `NoFinder` is the only finder (Steve, 12:58): it asks no one and answers nothing, so an address comes only from a public page the lead run names.
   - The cost card reads the database by resource from the day the bill carries it, because the cost reader groups the subscription's costs by resource id.

3. **Code renders the approved templates and the Function sends at 8 AM Central, in shadow first.**
   - The templates are Steve's v1 files, byte for byte, compiled into `JobHunt.Application` (`Features/Campaign/Templates/A-v1.md`, `B-v1.md`); `CampaignTemplate.Current` is the one constant a yes to v2 changes, with the v2 files.
   - `TemplateRender` fills every variable from the lead's row through `TemplateFill` and adds nothing: a plain body with the Markdown marks dropped and the links bare, and an HTML body in Gmail's own shape, a div a line, Links in bold and the five links a list of anchors named for what they open, each carrying its `?r=` code. A required blank renders nothing and names it. The renders of both templates, and of an optional line with and without its values, are held byte for byte against checked-in files.
   - `CampaignSend` runs every open lead through the rules in the review's order: former employer, do-not-email, an address with its public source, not bounced, a business domain with MX found, the do-not-apply list and the 30 days, a live thread, Track A within seven days, no blank, WhatTheyDo, no tell in the rendered email. Then it takes the cap (10, or 3 and 5 on the first two days of `on`), the LinkedIn order, Strength and the oldest lead. A lead whose own words fail (a blank, WhatTheyDo, a tell) is marked Blocked for the next lead run; the rest are counted by reason. Version 1 of Template A says "so I applied today" in words, so a Track A email goes out only when the application was today; a yes to v2 lifts that.
   - `CampaignSendTimer` (`CampaignSend` in the Function) fires every hour on the hour in UTC and acts in the 8 o'clock hour Central, which is 13:00 UTC now and 14:00 from November 1, seven days a week. It is idempotent by the day's count in `EmailsSent`. `EMAIL_CAMPAIGN` is `shadow` in the template: every would-send is written with `Shadow = 1` and both bodies, and the mail port is never built; a test holds that. `on` builds `GmailSender`, which sends a multipart message five seconds apart, refuses a header with a line break or a second address, and records the Gmail ids and the Message-Id. One line a pass goes to `campaign/pass.log` on the share, and the run line carries counts only.
   - `CAMPAIGN_SQL` is the connection string with the identity's client id and no secret, set in the template and, for this deploy, with `az functionapp config appsettings set`, which adds and replaces nothing else.

4. **The inbox pass reads what comes back: bounces, replies and requests to stop.**
   - A delivery failure arrives from `mailer-daemon@googlemail.com` in the thread of the email that failed, its plain body naming the address (`Final-Recipient`), the status and a heading such as "Address not found" (read from a real one of 2026-07-28; the test's copy has the address made up). `DeliveryFailure` reads it; the join to `EmailsSent` is the thread and the address together, so a failure for another message in the same thread is not this email's. The email is marked bounced once and the lead's address unverified with the reason.
   - Two failures in one Central day write a row to `campaign/switch.log` on the share: the Function cannot change its own settings, so the trip is a row the send reads before anything else, and while the newest row says `shadow` an `on` campaign writes and does not send. Only a person clears it, with a row of `on` and why. The pass's line says BLOCKED and the run comes to blocked, so the card goes red the hour it happens.
   - Once an hour (the pass at 20 past), each real send of the last 14 days with no reply is read for a sender who is neither Steve nor the mail system. The first such sender marks the email answered; the person waiting is then the reply run's, as any person waiting is. A reply whose own lines, above the quoted original, carry a stop phrase ("not interested", "do not contact", "unsubscribe", "remove me", "no thanks" and four more) marks the company do-not-email with the date, which the send reads before anything but a former employer, and lists the thread in `campaign/stop-threads.log`, which the reply run reads before it drafts (a CONVENTIONS fragment says so).
   - The cost: in shadow nothing was sent, so the part reads nothing. On, at ten a day, at most 140 threads are read once an hour, a metadata read each, about 5 % of the Function's free grant a month at the worst.

5. **The model writes rows to a file; the Function is the one writer of the database.**
   - The lead run is the leads list's run on GitHub Actions (Sonnet, low effort, `LEADS-LANE.md` in the job-hunt folder, rewritten), started by the inbox pass when it harvests a lead nobody has scored; `LEADS_LIST` is `on` from this version. It keeps scoring the leads list and adds the campaign's rows for the best of them: the company's key, names, remote stance, its .NET evidence rows (a posting's required stack, or its other engineering postings; .NET as one language of many does not count) and its public board; the engineering leader over the team; the address with the page it was read from; the WhatTheyDo clause; the track and the application's time from the tracker; the LinkedIn dates; the proof key from the posting's words; the follow-up post; the ghost score and the band; one tagged code from make_ref.py with its five links; and, for a new or changed lead only, its one step and its why. It reads the web for public pages only, with no finder, no sign-in and no guessed address, and it posts its usage under "Azure Functions, leads lane".
   - The run has no identity the database knows, so it writes one file to `campaign/leads-inbox/` on the share, and `LeadImport` reads it in the inbox pass: every company and lead held to `LeadSpool`'s rules (a registrable key, a named manager, Track A only with its application, an address only with its https page and never a personal one, a known proof key, links that carry the lead's code), a refused row named in `import.log` and written nowhere, a WhatTheyDo that fails kept with Blocked so the next run sees why, each new address asked for its MX records of a public resolver (`DohMxCheck`, dns.google, keyless), and Strength and its inputs hash computed by code. The file moves to merged/ or, unreadable, to rejected/.
   - The daily board scan (`BoardScan`) runs first in the 8 o'clock hour: one keyless request a company with a Greenhouse, Lever or Ashby board, the newest posting whose title names .NET or C# written back as the company's `LastDotNetPostingAt` and title, which Track B ranks by. At two hundred companies that is about 2 % of the Function's free grant a month.

6. **The campaign has a card, a rail row and an alert, and nothing on them can name a company.**
   - The card `campaign` under Cloud infrastructure, the rail's fifteenth row, in ADR-102's frame (its addendum of 1.0.4.63 says what it draws). It reads the database's `card` schema: `card.Days` (sent, written in shadow, bounced and answered by Central day, sixty days), `card.Leads` (open, with an address, waiting on one, blocked, no MX, unverified, companies, stopped, boards, the newest scan, the first email written and sent, the newest bounce) and `card.Weeks` (the Sunday table). The site's identity `id-theyard-ss` has a contained user on the campaign database made from its SID with `SELECT` on the `card` schema and nothing more, read back from `sys.database_permissions`. CampaignSqlTests holds the card views free of the columns that hold a name, a company, a subject or a code.
   - The alert `alert-jobhunt-campaign-silent` in `infra/jobhunt-alerts.bicep` (the review, F13): checked hourly, it fires only in the 15:00 UTC hour, after 8 AM Central under either clock, when no `CampaignSend` ran in the three hours before; it emails Steve through the action group the dead-Function alert already uses. The card goes red the same morning for two bounces, or for no send by 9 AM with leads ready.
   - CONVENTIONS gains three fragments: seven days a week (superseding the 12:22 fragment's weekdays), no third-party finder with public pages only, and the card shows no company; a fourth, from Decision 4, tells the reply run not to draft in a thread listed in `campaign/stop-threads.log`.

7. **A week of shadow is read from a report the Function writes, one a Central day.**
   - The 8 o'clock pass in shadow writes `campaign/shadow/<day>.md` on the share (`CampaignReport`): the pass's counts, the addresses of the written emails by the kind of page each was read from (the company's own site, GitHub, the posting, a public profile, another public page; the review's section 8 asks for this daily), every hold by its reason, and each would-send whole, with its address and the page it came from, subject and plain body. A later hourly firing that writes nothing leaves the day's report as it is.
   - In `on`, each real send is appended to `campaign/sent.log` in the block systemRules.md Rule 46 sets for every send (Steve, 2 October): a line of `--`, then **SENT** - **TO:** the name and address - **SUBJECT:** the subject, then `--`.
   - The report is the Function's and not a scheduled chat's, because nothing in this campaign hangs on a reminder: the seventh report is on the share whether or not anyone opened a session that week. The switch to `on` reads seven of them.

8. **A real send writes its tagged code's send-time row (the review, F17).**
   - The lead run mints each lead's code with make_ref.py when it writes the lead (Steve's rule; make_ref.py stays the only minter), so the mint-time row says "sent" days before the email goes, or for an email that never goes. When Gmail takes a campaign email, the Function writes one more file to `inbox/link-tokens/` on the share with the same code, the person, the company, the Gmail thread and the true send time, lane `campaign`. `opens_report.py` (on the share, its one writer of link-tokens.csv) now keeps the latest row a code field by field, so opens are labelled by the day the email went. Shadow writes no row: an email Gmail did not take has no thread.

## What holds it

`CampaignCardTests` (the dials, the four questions read and unread, red at two bounces and at no send by nine with leads ready, the Sunday table, every line sourced, the wire with no address or code, the source's minute and hour, the connection built from the site's own), `TemplateTests` (the silent-timer alert and the switch in the template), `LeadImportTests` (a made-up run file held to the rules row by row, the refusals named in the log, the blocked clause kept, the file moved, MX asked once an address, Strength computed by code, the scan's title rule and write-back, the three boards' and the resolver's answers read), `CampaignInboxTests` (the failure read off Gmail's own body, the stop phrases above the quoted original only, the newest switch row deciding, two failures today bouncing their emails once and tripping the switch once, one today and one yesterday not, a reply marking the email and a stop marking the company and listing the thread, nothing sent reading nothing), `CampaignSendTests` (both templates rendered byte for byte, the optional line, every variable given by the row, a blank named, the switch's words, the 8 o'clock hour across the clock change, shadow never reaching the mail port, ten of eleven sent and a second firing sending none, the three, five, ten start, every hold named, one email a company, the message's shape, the shadow report's counts, sources and whole emails, a real send's Rule 46 block, its send-time token row and none in shadow), `CampaignSqlTests` (every column a statement names is a column the SQL project declares, every template variable has its column, the template makes one Basic database and nothing else, the finder finds nothing), `RingTests` (the database client in Infrastructure alone), `CampaignRulesTests` (former employers by name and domain, the company key, the 30-day cases F10 names, the gate's order, the live thread over the tracker's real row shapes with every name removed) and `CampaignTextTests` (the tells against the Python file, the WhatTheyDo cases F15 names, the cap and ramp, the recipient domains, the Strength formula and hash, the applied word across the November 1 clock change, the proof clauses, the send order, the optional line and the named blank).

## Not done in this version, and why

The switch to on with the three, five, ten start is its own version, after seven shadow reports have been read.

## Where it sits

A new slice, `Campaign`, in the job hunt's Function rings, beside `Leads`, whose Rule 50 name match it reuses rather than copies. Separation of concerns is the principle: the model's judgement stays in rows a person can read and a rule can refuse, and the send is code a test can run. It cost one folder of rules and a copied Python file in the tests. What would change it: Steve approving a finder, a template with a {Proof} line, or a different cap, each a change to one constant or one adapter with its test.

## Files

- [`functions/JobHunt.Domain/Features/Campaign/`](https://github.com/SteveStout/TheYard/tree/main/functions/JobHunt.Domain/Features/Campaign) - the rules
- [`functions/JobHunt.Database/`](https://github.com/SteveStout/TheYard/tree/main/functions/JobHunt.Database) - the campaign's schema
- [`functions/JobHunt.Database/JobHunt.Database.sqlproj`](https://github.com/SteveStout/TheYard/blob/main/functions/JobHunt.Database/JobHunt.Database.sqlproj) - the schemas, which live in the site's database since 1.0.4.72 (infra/jobhunt-sql.bicep, the database of their own, is retired)
- [`functions/JobHunt.Application/Features/Campaign/CampaignPorts.cs`](https://github.com/SteveStout/TheYard/blob/main/functions/JobHunt.Application/Features/Campaign/CampaignPorts.cs) - the ports
- [`functions/JobHunt.Infrastructure/Features/Campaign/`](https://github.com/SteveStout/TheYard/tree/main/functions/JobHunt.Infrastructure/Features/Campaign) - the database adapter and the finder that finds nothing
- [`functions/jobhunt.Tests/CampaignRulesTests.cs`](https://github.com/SteveStout/TheYard/blob/main/functions/jobhunt.Tests/CampaignRulesTests.cs) - the company rules, held
- [`functions/JobHunt.Application/Features/Campaign/`](https://github.com/SteveStout/TheYard/tree/main/functions/JobHunt.Application/Features/Campaign) - the templates, the renderer and the send
- [`functions/jobhunt/CampaignSendTimer.cs`](https://github.com/SteveStout/TheYard/blob/main/functions/jobhunt/CampaignSendTimer.cs) - the 8 o'clock timer
- [`api/TheYard.Api/Features/Admin/Cloud/CampaignCard.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Features/Admin/Cloud/CampaignCard.cs) - the card
- [`api/TheYard.Tests/CampaignCardTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/CampaignCardTests.cs) - the card, held
- [`functions/jobhunt.Tests/LeadImportTests.cs`](https://github.com/SteveStout/TheYard/blob/main/functions/jobhunt.Tests/LeadImportTests.cs) - the lead run's file and the board scan, held
- [`functions/jobhunt.Tests/CampaignInboxTests.cs`](https://github.com/SteveStout/TheYard/blob/main/functions/jobhunt.Tests/CampaignInboxTests.cs) - bounces, replies and stops, held
- [`functions/jobhunt.Tests/CampaignSendTests.cs`](https://github.com/SteveStout/TheYard/blob/main/functions/jobhunt.Tests/CampaignSendTests.cs) - the renders and the send, held
- [`functions/jobhunt.Tests/CampaignSqlTests.cs`](https://github.com/SteveStout/TheYard/blob/main/functions/jobhunt.Tests/CampaignSqlTests.cs) - the schema, the template and the finder, held
- [`functions/jobhunt.Tests/CampaignTextTests.cs`](https://github.com/SteveStout/TheYard/blob/main/functions/jobhunt.Tests/CampaignTextTests.cs) - the text and order rules, held
- [`functions/jobhunt.Tests/Fixtures/ai_tells_five_signs.py`](https://github.com/SteveStout/TheYard/blob/main/functions/jobhunt.Tests/Fixtures/ai_tells_five_signs.py) - the copy the tells are held to

## Addendum, 2026-10-25 (1.0.4.72): one database, the campaign and the tracker as schemas

Decision 2 gave the campaign a Basic database of its own, `sqldb-jobhunt-ss`, about $4.90 a month, so that a deploy of it touched nothing else. On 25 October Steve asked for one database with the campaign and the tracker as schemas. A second fixed-price database for a few hundred rows is the cost the decision bought its separation with, and the separation is kept by permissions instead.

From this version the `campaign` and `card` schemas, and a new `jobhunt` schema for the tracker's tables (PROMPT-JOBHUNT-TRACKER-SQL), live in the site's own database, `sqldb-theyard-ss-basic`, beside the site's tables. `functions/JobHunt.Database` is still their authority and still compiles to its own DACPAC; the site's `api/TheYard.Database` names none of them.

- **The Function's identity** has a contained user in the site's database made from the same SID (`0xDD6CF11F4D186148BF294DCB9E7D89C0`), with SELECT, INSERT, UPDATE and DELETE granted on the three schemas only, and no database role. It cannot read a site table.
- **The site's identity** is denied the `campaign` and `jobhunt` schemas and granted SELECT on `card`, so the campaign card still cannot show a name, a company or an address.
- **The rows moved before the switch.** Every row of the four tables was copied with its Id, then both sides' counts and checksums were read back equal. Only then were `CAMPAIGN_SQL` and the site's `Azure:Resources:CampaignDatabase` pointed at the site's database, and the rows written in between were copied a second time. The next lead import was read in the new home.
- **`sqldb-jobhunt-ss` goes** once the next lead import reads back in the new home (ADR-106: unused resources are deleted). `infra/jobhunt-sql.bicep` is retired in this version, so nothing can make the database again, and `infra/jobhunt.bicep` names the site's database. The cost card stops carrying the second database from the day the bill does.

`CampaignSqlTests.The_schemas_live_in_the_site_s_database_and_no_template_makes_a_second_one` holds the template side. The grants were read back from `sys.database_permissions` and are written in J:\mentor\DOTNET10-DONE-2026-10-25.md.

## Addendum, 2026-10-25 (1.0.4.86): `sqldb-jobhunt-ss` is deleted

The condition the addendum of 1.0.4.72 set was met and the database is gone. Read at 23:00 CDT on 25 October through the queue (J:\staging\sunday1025\onedb\onedb-dropold.txt): the old database held Companies 141, CompanyEvidence 244, Leads 3 and EmailsSent 0; the site's database held Companies 141, CompanyEvidence 244, Leads 5 and EmailsSent 0, and had taken a write since the switch at 15:09 CDT (a lead updated at 21:20 CDT) while the old one had taken none. Only then was `az sql db delete` run, at 23:00:22, and `az sql db show` read ResourceNotFound at 23:00:41. Nothing in infra/ names the database (`CampaignSqlTests` holds it), so no template can make it again. The campaign and the tracker live in `sqldb-theyard-ss-basic` under their own schemas, and the subscription carries one Basic database fewer, about $4.90 a month.

## Addendum, 2026-10-26 (1.0.4.89): a date goes to SQL as a date

The first morning with `EMAIL_CAMPAIGN` on would have failed before it sent anything. To count the day of `on` for the ramp, the send asked the email log for every row since `DateOnly.MinValue`, the year 1. The store turned that day into a `DateTime` and handed it to `AddWithValue`, and Microsoft.Data.SqlClient sends a `DateTime` it was not told the type of as SQL's old `datetime`, whose range starts in 1753. The client refused it before the query left the Function ("SqlDateTime overflow"). Shadow never reached the line, because shadow has no day of `on`, so every shadow morning passed and no test saw it: the test's memory log accepted any day.

From this version:

- **The day count has its own question.** The email log answers the Central days that carry a real send (`OnDaysAsync`: the distinct `CentralDay` of every row with `Shadow = 0`), and the send counts the ones before today. No query asks for "everything since the beginning of time".
- **A date to SQL is `SqlDbType.Date`.** The store passes a `DateOnly` and its one command builder adds it as a `date` parameter, so a date never travels as `datetime` and never meets the 1753 floor. The columns it is written to (`CentralDay`, `FirstSeen`, `LastDotNetPostingAt`) are `date` already.
- **The test's log holds SQL's range.** The memory log the send tests use refuses a day before 1753 the way the client does, so a path that asks for one fails in the test and not on the first real morning. A test drives the `on` path with one real send on an earlier day and reads the second day's cap of five.

`CampaignSqlTests.A_date_goes_to_SQL_as_a_date` and `CampaignSendTests.The_on_path_counts_its_days_with_dates_SQL_accepts` hold it.

## Addendum, 2026-10-26 (1.0.4.90): one slow board does not stop the morning

The board scan runs first in the 8 o'clock hour, one board after another, and the send waits for it. A board that did not answer was meant to be skipped, but only a network error was treated that way. A board that held the request past HttpClient's 100 seconds surfaces as a cancellation, and a board that answered with a page of HTML instead of its JSON surfaces as a parse error; either one escaped the scan and failed the whole pass before the send, so that morning would have sent nothing and written no line.

From this version:

- **Each public request has fifteen seconds.** The board reader and the MX check each wait at most fifteen seconds a request, and treat a timeout, a body that is not JSON and JSON of the wrong shape the same way as a network error: the service did not answer, the port says null, and the scan moves to the next board. A cancellation the host asks for, when the Function is stopping, still goes on up.
- **The scan cannot stop the send.** The timer runs the scan inside its own guard. A scan that throws anyway is logged as an error and its part of `campaign/pass.log` reads `scan: FAILED: <the exception's type>`, and the send runs on the postings already in the book.
- **The pass line says how long the scan took**, in whole seconds, so a scan creeping toward the host's ten-minute limit shows before it reaches it.

`LeadImportTests.A_board_that_does_not_answer_in_time_or_answers_with_a_page_is_not_answered`, `LeadImportTests.A_resolver_that_does_not_answer_in_time_or_answers_with_a_page_is_not_known` and `LeadImportTests.A_scan_that_throws_is_written_as_failed_and_the_send_still_runs` hold it.

## Addendum, 2026-10-26 (1.0.4.99): no send by nine counts only a morning the switch was on

Read live on 26 October at 14:39 CDT, the campaign card was red on both names: "0 sent today", "none sent by 9 AM with 92 ready". The switch had been in shadow at that morning's 8 o'clock pass, which wrote its emails in shadow as it should, and it went on at 10:40 CDT (`J:\campaign\switch.log`, `J:\staging\addr1026\on.txt`). The first real sends belong to the next morning. Until this version the rule above, red for no send by 9 AM with leads ready, asked only whether the switch read `on` now, so any day the switch went on after eight read as a morning that failed to send.

From this version the rule asks what the switch read at the pass: the newest hour the card kept on the same Central day before 8 AM. When that hour read anything but `on`, the row stays good and says so ("the switch was shadow at the 8 AM pass; the first sends come at the next one"). When it read `on`, or no hour before eight was kept that day, the rule is what it was. Two bounces in a day stay red whatever the switch read. No threshold moves and nothing that emails Steve changes: `alert-jobhunt-campaign-silent` reads `CampaignSend`'s runs, not this card.

`CampaignCardTests.A_switch_turned_on_after_the_8_o_clock_pass_is_not_a_morning_with_no_send` holds it.

## Addendum, 2026-10-26 (1.0.4.103): a company mailbox, a test send, a daily lead run and a finder kept off

Lane addr1026 built these on 26 October at Steve's word (07:28, "any email will do, even if it's the companies about me email"; 09:33, "do the testing"), and they shipped as one version. Each changes the Function only; the site's rules do not move.

- **A company mailbox is a lead.** A lead with no first name opens on the greeting alone, with no name and no comma, so Template A and B can go to a company's published general address. `LeadSpool` takes a lead with an address and no manager named, and still refuses one that names neither.
- **A test send on demand.** `POST /api/campaign/test-send`, with the function key, renders one open lead through the real Gmail sender to the one address in the app setting `CAMPAIGN_TEST_TO` and never to the lead: the subject is marked [TEST], the links carry the code tst001, nothing is written to `EmailsSent`, and neither the cap nor the 30-day rule is spent. With the setting absent it answers 409 and sends nothing.
- **The book is worked every day.** Besides the run a newly harvested lead triggers, the leads run fires once a day in the pass after half past ten at night Central, marked on the share so a second pass in the slot does not fire it again.
- **A finder, kept off.** The "no finder" ruling of 24 October still stands in practice: `HunterFinder` sits behind the finder port and is built only when the app setting `HUNTER_KEY` holds a key, which it does not. With a key it would ask only for a lead whose public pages gave none, keep an answer scored 70 or more that cites a public page, and stop at the free plan's 50 calls a month. Turning it on is Steve's call, after the first shadow week's numbers.

`CampaignSendTests`, `LeadImportTests`, `AddressSearchTests` and `AtsSweepTests` hold it. Shipping puts the code on main; the Function runs it once it is deployed from that commit.

## Addendum, 2026-10-27 (1.0.4.106): the company mailbox is declared, never inferred from a missing name

The addendum of 1.0.4.103 meant one shape: a lead whose public pages name no engineering leader, written to the company's own published address. The code took a wider one. `LeadSpool` took any lead with a blank first name as long as it had an address, and the render made the first name optional for every lead. So a lead that named a manager (Pat Lee, VP Engineering) at a personal work address, where the model left the first name out, was written to the book and would have gone to that person as "Good morning!" with no name. The review of 27 October found it (D1) while the switch was on.

From this version:

- **A company mailbox says so.** A lead is a company mailbox only when its contact title is "Company mailbox", the shape the lead run writes for one. Nothing else makes it one: not a blank first name, not the address, not the contact's name.
- **Every other blank first name is refused.** `LeadSpool` refuses a lead with a blank first name that is not a company mailbox, with "no manager named". A company mailbox still needs its address.
- **The render holds the same line.** `TemplateRender.OptionalFor` lets the first name be blank only for a company mailbox. For any other lead a blank first name is a blank, and the email is incomplete and never sent, so a row already in the book from 1.0.4.103 is held at the 8 o'clock pass rather than sent.

`LeadImportTests.A_company_mailbox_with_no_first_name_is_written_and_a_named_contact_with_none_is_refused`, `CampaignSendTests.A_named_contact_with_no_first_name_is_an_incomplete_email` and the template test's blank first name hold it. Shipping puts the code on main; the Function runs it once it is deployed from that commit.

## Addendum, 2026-10-27 (1.0.4.108): the book run's mark, the finder's month, the test address and the 8 AM card

The review of 27 October found five smaller places where the campaign's parts said more than they knew. From this version:

- **The day's book run is marked only when GitHub took it.** The dispatch answers its line rather than throwing, so a refused token or a missing one used to write the day's mark anyway and that day's leads run was lost. The mark is written only on a 204 (`Dispatch.Delivered`), so a refused dispatch is tried again at the next pass in the slot, and the slot is read from the pass's own start rather than the time its sweep ended.
- **The finder asks about a lead once a month.** A lead the finder had no answer for was asked again every thirty minutes, spending the month's calls on the same names. Each lead asked is written to `campaign/finder/<finder>-<month>-asked.txt` and skipped for the rest of the month, and the count and that list are written even when a call throws, so a failed pass never lets the next one spend past the cap.
- **"off" turns the finder off.** `HUNTER_KEY` set to `off` builds no finder, as every other switch reads it.
- **The test send goes to one address.** `CAMPAIGN_TEST_TO` must be one plain address (`MailAddress.TryCreate`, and nothing beside it); a list or a display name answers 409 and sends nothing, where it threw a 500.
- **A silent shadow morning is a morning with no send.** The 8 AM card's switch at the pass is read from the newest hour kept before eight, about a minute past seven, not at the pass itself. So a switch read as shadow at seven is taken as the pass's mode only when the day's shadow count shows the pass wrote something; a shadow morning that wrote nothing is red like any other morning with no send. The better fix, the Function writing the mode its 08:00 pass ran in, is not in this version.

`AtsSweepTests`, `AddressSearchTests`, `CampaignSendTests` and `CampaignCardTests` hold it, the card's newest-of-several-early-hours and the day the clock goes back included.
