Skip to content

doctor(Email): guard the drain's failure path, cut the dead backfill arm, retire the prose about a section that moved - #1790

Merged
peterdrier merged 16 commits into
mainfrom
section-doctor/2026-09-22T011603Z
Sep 22, 2026
Merged

peterdrier merged 16 commits into
mainfrom
section-doctor/2026-09-22T011603Z

Conversation

@peterdrier

@peterdrier peterdrier commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

What

The daily section-doctor run on Email. One real defect fixed in the outbox drain, one dead query arm cut, and the section's comments and docs brought back in line with the code after three recent PRs moved things out from under them.

The full assessment, every finding with its disposition, and the retro are in docs/health/runs/2026-09-22-Email.md. The regenerated target shape is src/Sections/Humans.Email/Docs/health.md.

Why

Email had just absorbed the template move out of the renderer (#1651), the daily send tally (nobodies-collective#1195) and the pause moving to /Settings#email (#1634). Most of what this run found is the wake of those: prose describing a section that no longer exists.

The defect is not. EmailOutboxProcessor mirrored the campaign-grant status unwrapped inside the per-message catch. A throw from another section's bookkeeping had nowhere left to land — it escaped the loop and ProcessQueuedAsync itself, so the message never got its error log, the pending meter was never set, and the rest of the batch stayed stamped PickedUpAt until the five-minute stale window released it. The success path has always wrapped the identical call in TryUpdateGrantEmailStatusAsync, with a comment explaining exactly why.

Behaviour changes on the error path only, and only in the direction the success path already had: a grant-mirror failure there is now logged and swallowed instead of aborting the drain. Nothing else in this PR changes what the section does.

Existing surface checked

No new durable surface. The grant-mirror fix reuses the private helper that was already there; the query keeps its one call site under a narrower name (GetSentOrFailedSinceAsync → GetSentSinceAsync); the strengthened tests extend cases that already existed. Considered and rejected: a shared helper for the duplicated test-address rule — neither EmailOutboxProcessor nor EmailOutboxService is its obvious owner and the third option invents a type, so it is ledgered as EMAIL-3 rather than decided here.

UI changes / screenshots

None. No view, resx or route changed, so there is nothing to render. This was an unattended cloud run: the app was never booted.

Checklist

  • Section labeled — Email.
  • Targeting main on peterdrier/Humans (the QA fork).
  • Branched off origin/main.
  • Issue refs are qualified.
  • EF migrations — no schema change; a run never makes one.
  • NuGet packages — none touched.
  • New project rule — the one durable lesson this run hit (phantom Razor errors from a concurrent build) is already memory/process/no-concurrent-roslyn.md; no new atom.
  • Reuse-first checked — see above.
  • Build + test pass locally — full dotnet test Humans.slnx -v quiet green on the merged head (49 test projects, 0 failures); Humans.Integration.Tests self-skips under cloud by design.
  • Nav coverage — no new page.
  • No magic strings.
  • Dates/times via NodaTime — no new date handling; existing IClock usage untouched.

What else changed

  • Cut the comments naming things the section no longer has: the renderer in the architecture test's header, a retired analyzer rule and the assembly it swept, a test project that no longer exists, and the Application-layer / Infrastructure-layer labels for the projects the G5 split dissolved.
  • Cut eight sites narrating where the section used to be. Where the history carried a live reason — why the jobs are public, why the immediate processor sits under Contracts/ — the reason stays and only the itinerary goes.
  • Realigned the four section docs: the feature doc's dashboard pause, the authorization table's missing EmailPreviewController (the one endpoint open to any authenticated human), data-access.md's service lifetime and a section-internal interface filed as cross-section, and two stale freshness triggers in the guide.
  • Strengthened the test-address test: it asserted only that the tally stayed empty, which an unprocessed row also satisfies. It now asserts the row reaches Sent and the transport is never called.
  • Retired EMAIL-2, which blamed a deleted file. The defect was live and off-section, so this run re-filed it as GOOGLE-3, ISSUES-9 and SHIFTS-5 — and then deleted all three again, unworked, when the second base merge showed main had already fixed it. See Later. The same retired-rule comment in three other sections' test projects is CENTRAL-65, which survives: all three architecture test files still reference the rule.

Reviewer notes

Both non-mechanical changes went through doctor-reviewer-critical, and both came back APPROVE-with-correction on the same failure mode — a test that passes without pinning anything. Both corrections are applied and named in the run file. The reviewer verified that dropping the service-side status filter loses no guarantee: SentAt is written only by MarkSentAsync and pickup requires it null, so no non-Sent row can carry one.

Needs Peter — answered

All three are settled; nothing here is waiting.

  1. Bring every human-composed email up to Markdown + EasyMDE + styled, previewed, image-capable nobodies-collective/Humans#1194 — close it? → close. Closed as completed, with the criterion-by-criterion audit recorded on the issue. The Gmail/Outlook screenshot half was not refiled.
  2. Localize the hardcoded-English email templates in Events and Tickets #1657 — retarget it. → retarget. Title and body now name EventsEmails in Events and TicketsEmails in Tickets as the sites carrying the English literals; section:email replaced by section:events + section:tickets.
  3. The two things the send log is asked to be (EmailOutboxMessage as both retry queue and GDPR record). → leave it. One table with two jobs is the right answer at this scale; no issue filed.

Later

  • First base merge (79fc8db48). One conflict, in src/Sections/Humans.Email/Docs/authorization.md: both sides had added the EmailPreviewController row. Took main's, which additionally names the two actions and the per-human rate limit on SendMarkdownToSelf — verified against EmailPreviewController and the policy registration in Section.cs. Everything else auto-merged; main's ThrottleDelayAsync seam and this PR's guarded failure-path mirror both survive in EmailOutboxProcessor.

  • Two review rounds, both Codex, both real.

    • P1 on review: routing. EMAIL-3 was filed review: light when its own text says neither class is the obvious owner. Changed to panel, and the same audit applied to the other four rows — EMAIL-4 also became panel; EMAIL-5 through EMAIL-7 stay light, each naming the exact invariant to pin and the file that lacks it.
    • P2 on line numbers in ledger prose. The skill is explicit that a ledger entry carries no line numbers, and all seven rows this run filed had file:line anchors. Stripped, keeping paths and symbols — the run file keeps its anchors, which is where the evidence bar wants them. The same pattern in rows earlier runs filed is pre-existing and left alone.
  • Second base merge (153216e4c), and the more interesting one. The textual conflict was small — only src/Sections/Humans.Email/Docs/debt.yml, where both sides had closed EMAIL-2 and this branch had since added EMAIL-3–7. Kept ours; next_id only ever increases.

    The semantic conflict was larger. Main's nightly sweep (Daily tech-debt sweep — 2026-09-22 #1789) had independently fixed the subject double-encoding that this run re-filed as GOOGLE-3, ISSUES-9 and SHIFTS-5. Verified on the merged tree rather than on the claim: at every site those rows named, the subject argument now passes raw and only the body arguments are encoded. All three rows are deleted, unworked — a ledger row for a bug that is already fixed is worse than no row. next_id is preserved in each ledger (Google 4, Issues 10, Shifts 6), so no id is recycled. The findings were real when filed; they would have been stale the moment they shipped.

    CENTRAL-65 was checked against the same sweep and left standing — IRepositoryImplementationsAreSealedRule is still referenced in all three architecture test files it names.

    Neither base merge is a review round: rounds spent stay at 2 of 5.

UPCOMING

Next four sections by the selector's priority, so you can redirect it before tomorrow's run: Gdpr, CityPlanning, Expenses, Store.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3

… its owning sections

EMAIL-2 named src/Sections/Humans.Email/Services/EmailRenderer.cs, deleted in
#1651. The defect is still live, but the templates moved out with
the renderer, so the fix now lives in three other sections: GoogleIntegration
(GoogleIntegrationEmails.cs:38,46,54), Issues (IssuesEmails.cs:31) and Shifts
(ShiftsEmails.cs:36,76). Debt belongs where the next reader of that section meets it
(memory/process/debt-ledger-additions.md), so the row is closed here and re-filed there.

Also files the doctor run's own out-of-scope finds against Email: the duplicated
test-address rule, the hardcoded dashboard throttle figure, and three unpinned halves
of the section's invariants.
EmailOutboxProcessor mirrored the campaign grant unwrapped inside the per-message
catch. A throw from another section's bookkeeping had nowhere left to land: it
escaped the loop and ProcessQueuedAsync itself, so the message never got its error
log, the pending meter was never set, and the rest of the batch stayed stamped
PickedUpAt until the five-minute stale window released it.

The success path already wraps the identical call in TryUpdateGrantEmailStatusAsync
with a comment saying why. The failure path now uses the same helper, and the test
reproduces the abort against the old code.

Behaviour changes on the error path only: a grant-mirror failure is now logged and
swallowed there, as it already was after a successful delivery.

Reviewed by doctor-reviewer-critical: APPROVE-with-correction (two health.md line
anchors, applied).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
The old target described a renderer pipeline that #1651 deleted and
carried a Needs-Peter question the same PR answered. The new one adds the daily tally
(nobodies-collective#1195) and the pause's move to /Settings#email
(#1634), gives every invariant an enforcement cite, and names the
shape the code does not yet have: a name for each of the two things the send log is
asked to be — the retry queue and the per-human record.

Run file carries the assessment, the findings and the 3f debt verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
GetSentOrFailedSinceAsync loaded Failed rows that its only caller filtered straight
back out, so the failures-are-never-backfilled rule from
nobodies-collective#1195 was written twice — once as a query arm that fetched
them, once as the filter that threw them away. It is now stated once, in a query
whose name says it: GetSentSinceAsync.

The service-side status filter goes with it. SentAt is set only by MarkSentAsync and
pickup requires SentAt null, so no non-Sent row can carry a SentAt and the SentAt
dereference downstream stays safe.

The service test that fed a Failed row now feeds a Sent one with RetryCount 2 — the
tempting case, a message that failed twice before it went out — and still asserts
FailedCount 0; the repository test carries the exclusion itself.

Reviewed by doctor-reviewer-critical: APPROVE-with-correction (the test was vacuous
as first written, applied).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
…r has

Three claims went stale under #1651 and the G5 split: the
architecture test's header listed "the renderer" among the section-internal pieces
(EmailRenderer.cs was deleted), its sealed-repository note cited a rule
docs/architecture/roslyn-analysis.md records as retired along with the
Humans.Infrastructure assembly it swept, and OutboxEmailServiceTests described two of
its own members as borrowed from Humans.Application.Tests, a project that no longer
exists.

The Application-layer / Infrastructure-layer descriptors go with them: both projects
dissolved in the split and every type they qualify is section-internal now, so the
labels record the old shape rather than this one.

The same retired-rule comment sits in three other sections' test projects, which this
run does not touch — filed as CENTRAL-65.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
Eight sites told a reader about a move rather than about the code in front of them:
the invariant doc's account of the TriggerImmediate field, the DbContext peel, the FK
and nav removals, and the two jobs' route from Base into Jobs/; the settings tab and
its view component on the toggle's old home; and the job's rebuttal of a claim nobody
makes here any more.

Where the history carried a live reason — why the jobs are public, why the immediate
processor sits under Contracts/, why the assembly is not load-bearing — the reason
stays and only the itinerary goes.

Also drops "the renderer" from the doc's list of section-internal pieces: #1651
deleted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
- The feature doc still put the pause/resume toggle on the outbox dashboard;
  #1634 moved it to /Settings#email and left a link behind.
- authorization.md listed only EmailController, so the one endpoint in the section
  open to any authenticated human — EmailPreviewController's POST /Email/PreviewMarkdown
  — appeared nowhere in the table a reader checks for exactly that.
- data-access.md recorded EmailPreviewService as Scoped where Section.cs registers it
  singleton, and filed the section-internal IEmailBodyComposer among OutboxEmailService's
  cross-section calls.
- Two of the guide's freshness triggers watched a view and a controller that no longer
  carry the Profile Emails surface; they now point at ProfileEmails*.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
It checked only that the daily tally stayed empty, which a message that was never
picked up would also satisfy. The invariant is narrower than that: a test-domain row
is marked Sent deliberately, without a transport call, because sending to it bounces
and costs sender reputation. Both halves are now asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
Findings, the 3f verdict, what was worked, what was left and why, the retro and the
three questions for Peter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-22T22:14:45.040634Z c6f302e New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown

Reviewed commit 55ddbbe — no issues found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
@peterdrier

Copy link
Copy Markdown
Owner Author
Component Phase Model Fresh in Out Cache write Cache read ~$
worktree phase1 opus 20 3,282 40,872 1,024,778 0.85
assess phase3 opus 198 101,440 767,629 18,429,485 16.55
Freshness (subagent) phase3 opus 58 5,822 373,737 1,072,341 3.02
Tests (subagent) phase3 opus 76 12,561 466,479 2,041,293 4.25
History (subagent) phase3 opus 24 5,760 125,192 219,306 1.04
Comments (subagent) phase3 opus 38 8,234 232,451 1,027,229 2.17
Inbox (subagent) phase3 opus 48 6,970 129,058 720,102 1.34
Prose & surface (subagent) phase3 haiku 498 124 349,983 3,278,536 0.77
verify-debt phase3f opus 376 121,501 358,237 39,121,298 24.84
review route the failure-path campaign-grant mirror through the same guard the success path uses (subagent) phase3f fable 680 7,054 505,976 814,230 7.50
review drop the dead Failed arm from the backfill query and rename it GetSentSinceAsync (subagent) phase3f fable 744 1,338 589,278 915,730 8.36
total 2,760 274,086 3,938,892 68,664,328 70.68

API-equivalent $, list rates; run under subscription quota. Measured Phase 1 to PR creation; PR create/backfill and Phase 8 excluded.

Peak main-thread context: 289,991 tokens (assess).
Compactions detected: 2 (assess, verify-debt).


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

PR Surface Report

Compared 153216e4...c6f302e8. Scored with reforge 0.29.0.

Summary: 28 changed file(s) | EF migrations: 0 added file(s), max 0/1 per context

Reforge Surface Score

metric base head delta
total 20521 20521 0
surface 16901 16901 0
internal complexity 3620 3620 0

Section Deltas

No section score changes.

Section Size & Complexity Deltas

section LOC delta files delta classes delta cognitive p95 delta
Email -6 0 0 0
Corpus -6 0 0 0

Corpus is the solution-wide rollup (same source as Corpus Size & Complexity below), not a sum of the section rows above - cognitive p95 in particular doesn't compose across sections.

Rule Deltas

No rule score changes.

Corpus Size & Complexity

metric base head delta
locProd 186559 186553 -6

At head: largest class SurveyService, most complex method DevelopmentDashboardSeeder.SeedAsync.

Published Write Surface

14 of 48 sections publish write capability, 23 interfaces (0).

Interface Surface

No new interfaces or interface methods.

Diff Size

bucket added deleted
code 46 52
tests 75 27
docs 453 127
other 25 5

New Files

  • docs/health/runs/2026-09-22-Email.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 55ddbbeb65

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Sections/Humans.Email/Docs/debt.yml Outdated
…view

memory/process/debt-ledger-additions.md sets one test for the field: review: light
only when the fix is rule-prescribed and the verifier is mechanical, otherwise panel.
Two of the rows this run filed fail it on their own evidence.

EMAIL-3 says in its own what that neither class is the obvious owner, which is why the
run ledgered it rather than picking one. EMAIL-4 is the same shape: the tile shows a
true figure under the wrong label, and relabelling it or computing the real one are
both defensible. Marked light, /debt-sweep would have skipped the second-opinion panel
and let one implementer's pick land unreviewed.

EMAIL-5 through EMAIL-7 stay light. Each names the invariant to pin and the file that
lacks it, so the remedy is prescribed and the verifier is a test that passes or does not.

Review-round: 1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
One conflict, in src/Sections/Humans.Email/Docs/authorization.md: both sides
added the EmailPreviewController row. Took main's, which additionally names the
two actions and the 5/min per-human rate limit on SendMarkdownToSelf — verified
against EmailPreviewController.cs:24,25,46 and Section.cs:62-71.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
1194 closed as completed, 1657 retargeted at Events and Tickets with its
label moved, the outbox-table split left as is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 085bbc02fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Sections/Humans.Email/Docs/debt.yml Outdated
The skill is explicit that a ledger entry is prose with no line numbers.
Seven rows this run filed carried file:line coordinates; paths and symbols
stay, the numeric anchors go.

Review-round: 2

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
…dy fixed

Textual conflict was only Email/Docs/debt.yml — both sides closed EMAIL-2,
this side then added EMAIL-3..7. Kept ours; next_id only ever increases.

The larger conflict was semantic. Main's nightly sweep (#1789) independently
fixed the subject double-encoding this run had re-filed, so ISSUES-9, SHIFTS-5
and GOOGLE-3 are deleted unworked: verified on the merged tree that every
subject argument at those sites now passes raw while the body arguments stay
encoded. The finding was real when filed; the row would have been stale on
arrival. next_id preserved in all three ledgers.

CENTRAL-65 checked against the same sweep and left standing — all three
architecture test files still reference the rule it names.

Run file records the deletions and Peter's rulings on the three Needs-Peter
items (close / retarget / leave it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCrRPogs7ZYUv9VwxewFZ3
@peterdrier
peterdrier merged commit 8208716 into main Sep 22, 2026
6 checks passed
@peterdrier
peterdrier deleted the section-doctor/2026-09-22T011603Z branch September 22, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants