Repository navigation
Slack analysis pages stay inside the 50-block message limit: details are budgeted like the prose, dropped from the end with the omission stated (#3612) - #3618
Conversation
…ed details are budgeted like the prose, whole details drop from the end with the omission stated, and every detail text object is capped (#3612) Three five-fact PLAN_REGRESSION analysis pages died on Slack with invalid_attachments across two builds while a three-fact page delivered through the same webhook. The monitoring seat's read of the persisted context_json ruled out text length (failed pages' longest body 2,122 chars; a delivered page carried 2,835) and named detail count: 19-21 items failed, 18 delivered. Reproduced from source: the story attaches seven drill-downs and one incident item per distinct query hash; blocks = 37 + 2 x incidents, so the seventh incident is the fifty-first block. The Details path now composes under the same discipline as #3493's prose: head and footer are costed first, the details take what remains (less one block reserved for prose when there is prose), the longest leading run that fits is kept and the rest is dropped with a divider + section naming the count and every dropped heading, pointing at email / in-app Alert Details. A body at the boundary takes what remains and states its own line omission. Bodies route through the same splitter (header parameterized), fields are capped at Slack's 2,000-char field limit with the cut stated inline. A page that fits is byte-identical. Teams: measured 16.6 KB at 21 items and 17.4 KB at the heaviest producible shape against the 28 KB connector cap; no Teams change, margin documented and pinned. Email has no such limit. Tests (Lite.Tests/SlackDetailsSizeTests.cs, 13 arms): the real producer path through FindingMessageFormatter.BuildContext with a five-fact fixture; 3 and 6 incidents byte-identical to the pre-change loop (oracle restated in the test); 7/9/10/11/15 incidents inside every ceiling with the omission stated and a guard that the old rendering exceeds 50; blocks-not-count; body-at-boundary; prose+details both heavy land on exactly 50; body split reassembly; field cut arithmetic; Teams cap pin. Executed here against the built DLL via a net10.0 shim (66 green incl. every other Slack pin that compiles off-Windows); CI is the first Windows execution.
| AddSlackProseSections(blocks, prose, | ||
| blockBudget: Math.Max(1, SlackMessageBlockLimit - blocks.Count - tailBlocks.Count)); | ||
| AddSlackProseSections(blocks, SlackProseHeader, prose, | ||
| blockBudget: SlackMessageBlockLimit - blocks.Count - detailBlocks.Count - footerBlocks.Count); |
There was a problem hiding this comment.
Non-blocking robustness note: this blockBudget (and the one passed into AddSlackDetailBlocks a few lines up, and the remaining - 1 passed into RenderSlackDetail from inside AddSlackDetailBlocks) is only ever >= 1 because of careful arithmetic across three call sites — there's no local guard. PackProseLines's repack loop (finalBlock = texts.Count == blockBudget - 1) depends on eventually hitting finalBlock == true to break before it walks placed past the end of lines; if a blockBudget <= 0 ever reached it, finalBlock would never be true and the trailing lines[placed] would throw once placed == lines.Count. Today that's unreachable (head/footer are small fixed constants and the remaining < 2 check upstream keeps body budgets >= 1), but the invariant lives entirely in caller-side subtraction rather than in PackProseLines/AddSlackProseSections themselves. A Math.Max(1, blockBudget) at the top of one of those two functions would make the safety local instead of relying on every future edit to this budget math getting it right.
There was a problem hiding this comment.
Taken, in 4e20f4a: AddSlackProseSections now passes Math.Max(1, blockBudget) into PackProseLines, with the reason stated at the site (the repack loop terminates by reaching its final block, which a budget of zero would never present, and no section can render under a budget of one anyway). Every caller still hands in at least one — the head/footer arithmetic and the remaining < 2 guard are unchanged — so no payload changes shape; the invariant just stops living only in three call sites' subtraction. The #3493 doc block's sentence about the floor was updated to match. Re-ran the 66-arm shim against the rebuilt assembly: green.
|
Reviewed the diff ( Correctness: Traced the block-budget arithmetic across Parity: Security: No new external input, file, network, or process surfaces; payload values go through anonymous-object → Tests: No blocking issues found. |
There was a problem hiding this comment.
LGTM — traced the Slack block-budget arithmetic (BuildSlackPayload → AddSlackDetailBlocks → RenderSlackDetail → AddSlackProseSections → PackProseLines) end to end, confirmed the invariants that keep it within the 50-block cap and never silently drop content, verified Lite/Darling parity (shared Notifications library, called from both apps), checked security surface (no new external input/injection risk), and spot-checked the new test suite's math against the implementation. One non-blocking robustness note left inline.
…k loop's termination does not depend on three call sites' subtraction (#3618 review note)
| while (kept < details.Count) | ||
| { | ||
| var remaining = blockBudget - omissionCost - spent; | ||
| var run = runs[kept]; | ||
| if (run.Count > remaining) | ||
| { | ||
| var detail = details[kept]; | ||
| /* One divider plus at least one section is the least a body detail can cost. */ | ||
| if (string.IsNullOrEmpty(detail.Body) || detail.IsCodeBlock || remaining < 2) | ||
| { | ||
| break; | ||
| } | ||
|
|
||
| run = RenderSlackDetail(detail, bodyBlockBudget: remaining - 1); | ||
| if (run.Count > remaining) | ||
| { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| blocks.AddRange(run); | ||
| spent += run.Count; | ||
| kept++; | ||
| } | ||
|
|
||
| if (omissionCost == 0) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var dropped = details.Count - kept; | ||
| var list = new StringBuilder(); | ||
| for (var i = kept; i < details.Count; i++) | ||
| { | ||
| if (list.Length > 0) | ||
| { | ||
| list.Append("; "); | ||
| } | ||
|
|
||
| list.Append(details[i].Heading); | ||
| } | ||
|
|
||
| var headings = list.Length <= SlackOmittedHeadingsLimit | ||
| ? list.ToString() | ||
| : list.ToString(0, SlackOmittedHeadingsLimit) + "..."; | ||
|
|
||
| var noun = dropped == 1 ? "detail" : "details"; | ||
| var omission = string.Create(CultureInfo.InvariantCulture, | ||
| $"*Omitted from this message*\n{dropped:N0} more {noun} did not fit Slack's {SlackMessageBlockLimit}-block message limit: {headings}{SlackOmissionPointer}"); | ||
|
|
||
| if (omissionCost == SlackDetailOmissionCost) | ||
| { | ||
| blocks.Add(new { type = "divider" }); | ||
| } | ||
|
|
||
| blocks.Add(new { type = "section", text = new { type = "mrkdwn", text = omission } }); |
There was a problem hiding this comment.
Correctness: a fully-fitting page can get a false "0 more details did not fit" omission block.
The while loop can reach kept == details.Count (i.e. every detail ends up kept) via the body-shrink branch (line 1177) rather than only by hitting the loop's natural end. That happens whenever the unbounded render of all details exceeds blockBudget (so the fast path at line ~1149 is skipped), but shrinking a body detail (e.g. Diagnosis or Advice, which are body-type and processed first in FindingMessageFormatter.BuildContext) recovers enough room that everything — including every detail after it — ultimately fits under blockBudget - omissionCost.
In that case dropped = details.Count - kept is 0, but the code doesn't check that before line 1214-1219: it unconditionally appends a divider + "*Omitted from this message*\n0 more details did not fit Slack's 50-block message limit: {SlackOmissionPointer}" (empty heading list, since the loop at 1196 never executes). This wastes 2 blocks and shows the recipient a factually wrong statement — nothing was dropped, one body was merely truncated (and that truncation is already stated inline by AddSlackProseSections's own line-level omission).
Minimal repro: two details where detail 0 is a small fixed-size field detail and detail 1 is a body whose full render is much larger than the budget but whose shrunk render (via RenderSlackDetail(detail, bodyBlockBudget: remaining - 1)) fits in what's left — with nothing after it. kept reaches 2 (== details.Count) through the shrink path, yet the function still emits a spurious "0 more details did not fit" section.
None of the new tests in SlackDetailsSizeTests.cs hit this: the "byte-identical" arms only exercise the fast path (total <= blockBudget), and the boundary-body arm (ABodyDetailAtTheBoundary_TakesWhatRemains_AndStatesItsOwnOmission) deliberately adds trailing details after the shrunk body so dropped is always > 0 there.
Suggest guarding on dropped == 0 (return before building the omission text) in addition to the existing omissionCost == 0 check.
There was a problem hiding this comment.
Correct, and thank you — fixed in 350cdaf, and the defect was one step wider than the repro: the reserve was charged against the LAST detail too, so besides the false "0 more details" item after a last body shrink, a last SMALL detail that would have fit in the reclaimed two blocks was dropped and replaced by an omission item of the same cost (less told for the same blocks). Now the last detail is offered the whole remainder (remaining = blockBudget - spent - (last ? 0 : omissionCost)) and the function returns before building the item when dropped == 0. For every non-last detail the reserve stands, and a body shrunk into it fills the remainder exactly, so the next detail necessarily drops and the item is always earned. Two new arms pin both shapes at exactly 50 blocks with no omission item (ALastBodyDetailPastTheBudget_ShrinksIntoTheWholeRemainder_WithNoOmissionItem — your repro — and ALastSmallDetail_FitsInTheReclaimedReserve_AndIsKeptRatherThanAnnounced). Re-executed the 68-arm shim and the five-fact harness: the fitting shapes remain byte-identical, the over-budget shapes still land at 49.
| kept++; | ||
| } | ||
|
|
||
| if (omissionCost == 0) |
There was a problem hiding this comment.
Minor/non-blocking: if blockBudget <= 0 (omissionCost becomes 0 via Math.Max(0, blockBudget)), this returns having appended nothing at all — every detail is silently dropped with no stated omission, which is exactly the "never silent" invariant this whole PR is built around. Currently unreachable in practice (head is 2 blocks, footer is 1-2, proseReserve is 0-1, so blockBudget is always comfortably positive today), so I wouldn't block on it, but it's worth a defensive note or an assertion so a future change to the head/footer composition can't silently regress this.
There was a problem hiding this comment.
Agreed it is unreachable (head 2, footer ≤ 2, reserve ≤ 1 → the caller hands in ≥ 45), and agreed the invariant deserves to be written down where it would break: 350cdaf adds the note at the omissionCost site — a budget of zero or less leaves no block to state anything in, so appending nothing is the only honest rendering of a budget that cannot exist, and a change to the head/footer composition is what the comment is for. Kept as a note rather than an assertion because the site is a webhook payload builder on the alert path, and throwing there would lose the delivery outright — the outcome this PR exists to prevent.
There was a problem hiding this comment.
Reviewed the Slack block-budget fix (WebhookAlertService.BuildSlackPayload / AddSlackDetailBlocks / AddSlackProseSections / RenderSlackDetail / SlackFieldText) plus the new SlackDetailsSizeTests.cs. The design and most of the edge-case handling (byte-identical fast path, block-not-count budgeting, per-text-object caps, field-cut arithmetic, Teams cap) is solid and well tested. One substantive bug found:
- False "0 more details did not fit" omission (
AddSlackDetailBlocks, WebhookAlertService.cs ~1164-1219). When the over-budget prefix loop reacheskept == details.Countvia the body-shrink branch (a body detail like Diagnosis/Advice gets shrunk to fit rather than dropped, and everything after it then also fits),droppedis0but the code still unconditionally appends a divider + "Omitted from this message\n0 more details did not fit Slack's 50-block message limit: ..." section. This wastes 2 blocks and shows recipients a factually wrong statement on a page where nothing was actually dropped — the mirror-image of the bug this PR is fixing. None of the new tests exercise this path (the boundary-body test always has trailing details after the shrunk body, sodropped > 0there). See inline comment for a concrete repro and suggested fix (guard ondropped == 0).
Also left a non-blocking inline note about a latent (currently unreachable) silent-full-drop path when blockBudget <= 0.
…nd no item is emitted when nothing was dropped (#3618 review catch) A last body shrunk into the reserved two blocks left dropped == 0 and a false "0 more details did not fit" item; a last small detail that would fit in the reclaimed two was dropped and replaced by an announcement of the same cost. The last detail is now offered the whole remainder and the function returns before the omission when dropped == 0. Two arms pin both shapes at exactly 50 blocks with no omission item; the zero-budget silent path carries its note.
Remediated in 350cdaf: the omission reserve is no longer charged against the last detail and no item is emitted when dropped == 0; both shapes pinned. The zero-budget note is in place. Dismissed so the guard re-evaluates against the new head's review.
|
|
||
| var keep = Math.Max(0, SlackFieldTextLimit - prefix.Length - Note(value.Length).Length); | ||
| var note = Note(value.Length - keep); | ||
| return prefix + value[..keep] + note; |
There was a problem hiding this comment.
Minor/non-blocking: value[..keep] (and the sibling truncation points — SlackHeading's heading[..SlackDetailHeadingLimit] at line 1284, the fragment cut at line 1072, and raw.Substring(start, take) at line 990) all cut on char (UTF-16 code unit) boundaries. If a query-text field lands a cut in the middle of a surrogate pair (astral-plane characters, e.g. some emoji, are plausible inside arbitrary SQL text/comments), the result carries a lone surrogate. JsonSerializer.Serialize here won't throw on that (unlike JsonEncodedText.Encode, per the comment above EscapeForJson), so it isn't a crash risk — worst case is a stray replacement glyph in the rendered card. Not worth blocking on given how narrow the trigger is, but might be worth a shared "trim to char boundary" helper if this pattern gets a fifth call site.
There was a problem hiding this comment.
Agreed on the mechanics and the severity (a stray glyph, never a throw — System.Text.Json's serializer replaces a lone surrogate rather than rejecting it). Not taking it in this PR: two of the four cut points are #3493's (the omission fragment and the hard-split continuation) and predate this change, so a shared trim-to-char-boundary helper is properly its own small PR touching both halves of the discipline at once rather than two of four sites here. I'll carry it in the lane report as a follow-up with your four line references.
There was a problem hiding this comment.
LGTM — reviewed the Slack block-budget algorithm (AddSlackDetailBlocks/AddSlackProseSections/PackProseLines/SlackFieldText) in WebhookAlertService.cs against the new SlackDetailsSizeTests.cs, hand-traced several of the trickier boundary cases (last-detail-gets-whole-remainder, last-small-item-reclaims-the-omission-reserve, prose-vs-details budget split), and confirmed the byte-identical-when-fitting claim against the old unbounded loop via the diff. No correctness issues found. This lives entirely in PerformanceMonitor.Notifications, a project shared by both Lite and Darling, so there's no Lite/Darling parity drift to flag. Left one non-blocking inline note about char-boundary truncation on a few of the new cut points (cosmetic risk only, not a crash).
…le, Opus full tier (#3710) (#3718) * REVIEW-TRAPS.md: the repository's recurring defect shapes, one line each with the PR that bit (#3710) Seven layers (SQL, collectors and deltas, analysis, alerting and notifications, storage and MCP payloads, tests and censuses, workflows and CI, repository mechanics), fifty-odd lines, every one citing the pull request or issue that verified it: the >= limit truncation inference (#3594/#3679/#3699), ORDER BY 3 + 4 folding to a constant (#3613), MAX(max_dop) across plans (#3648), integer division in cntr_value_per_second (#3653 Q9), per-collection alert firing against a shorter cooldown (#3628/#3636), restart zeros in deltas and baselines (#3595/#3698/#3705/#3708), ScoreAll skipping amplifiers on a zero base and the non-virtual GetActiveEdges (#3688), the PgTargetFactKeys censuses (#3688), pg_total_relation_size vs the heap and the two TimescaleDB views that hide materializations and history (#3610/#3585), the -infinity crash backoff (#3629), the Slack block budget and the surrogate-pair cut (#3612/#3618/#3625/#3644), LCK_M_SCH_M folding into LCK so no SCH_M fact exists (#3709), one wait profile per run (#3709), and the CRLF round-trip. Each citation was read before it was written down; a wrong citation is worse than none. The review workflow reads this file before the PR body and must say, per trap the diff is near, whether it applies here and why. * Claude review: blind read before the body, callers rule, structured verdict with a ledger, and an Opus/Sonnet tier by changed path (#3710) The prompt runs in two phases. Phase 1 forbids gh pr view / git log / git show until the reviewer has read the diff, every changed region in context, the callers of every changed public symbol (Grep, both SKUs and both test projects) and .github/REVIEW-TRAPS.md, and written its own account. Phase 2 reads the body and grades every claim VERIFIED / UNVERIFIED / REFUTED, then reports the DIVERGENCE between the two accounts. The verdict is a fixed block -- Claims, Riskiest lines with the pinning test or UNPINNED, Traps near this diff, Divergence, Reviewer checklist -- ending in one machine-readable ledger line, posted through gh pr review --body-file - with a quoted heredoc (no Write tool; the allowlist is unchanged and still read-only). The #3650 verdict check now fetches the newest verdict in the window by id and fails the job when the body lacks the "## Verdict:" header or the ledger, or when the ledger's verdict disagrees with the review's API state (a "CHANGES REQUESTED" body submitted with --comment is the shape the guard would wave through). The guard's own arm is untouched and still keys on the state. A dorny/paths-filter@v4 step (the repo's pin style) routes changes under the shared libraries, Darling Storage/Analysis/Service, Lite Services/Mcp/Database, install/ and every .sql file to the full tier -- claude-opus-5[1m], 60 turns -- and everything else to claude-sonnet-5 at 30 turns; one decision step emits tier/model/max_turns, one review step consumes them (two steps would double the "review posted" accounting the guard does), and the prompt carries the tier and model into the ledger. A filter that dies degrades to FULL, not light. Step name "Claude review" and the ALWAYS POST sentinel are unchanged: both are read by claude-review-guard.yml. * Review tier: Alerting and Notifications read at the full tier (#3710)
For #3612 — the watcher closes it at merge via the keyword below.
Three lost pages, and the read that named the breach
Three analysis notification pages died on Slack with
HTTP 400: invalid_attachmentsacross two builds — Sep 17 00:52Z and Sep 18 01:21Z on.444, Sep 18 15:42Z on 3.8.0 — while a three-fact analysis page delivered through the same webhook the same hour. All three were the same five-fact story rooted in a plan regression (PLAN_REGRESSION→CPU_SPIKE→PARAMETER_SENSITIVITY→QUERY_SPILLS→TEMPDB_USAGE, severity 1.75, confidence 0.80): the product's highest-severity compound finding, and the push is the point of it. The rows persisted honestly with their transport error (the #3446 arm), so this arrived as evidence.The brief's leading hypothesis was that the Advice body had crossed Slack's 3,000-character text-object ceiling — the #3493 failure one shelf over, on a text object #3493 never touched. The monitoring seat's store read refuted it before a line was written, and that read is the load-bearing fact of this PR: across all fifteen analysis pages in two days of
config_alert_log, the three failed rows' longest body was 2,105–2,122 characters and a delivered row carried a 2,835-character body. Text length does not separate them. Detail count does, cleanly: the failed pages carried 19, 20 and 21 detail items; the largest delivered page carried 18.The mechanism, measured from source
BuildSlackPayloadrenders every structured detail as a divider plus one or more sections — a body detail costs two blocks, a field-bearing detail costs one plus a section per ten fields — and the total was never checked against Slack's 50-block message limit. Slack rejects a fifty-first block with the sameinvalid_attachmentsstring it uses for an oversized text object, which is how #3612 hid behind #3493 for two builds.I rebuilt the lost page from source rather than guessing: the five-fact story, the seven drill-downs the Lite collector attaches for those fact keys (
spike_peak,queries_at_spike,top_cpu_queries,top_spilling_queries,tempdb_breakdown,parameter_sensitive_queries,regressed_queries), five rows each with 500-character query text, the value-stated advice frozen intoStoryTextbyFactAdvice.Compose, all throughFindingMessageFormatter.BuildContext— the same call the notification service makes. The producer emits ten fixed items (Diagnosis, Advice, the Remediation T-SQL pointer, seven drill-downs) and then one incident item per distinctquery_hashacross the three hash-bearing drill-downs, up to fifteen. That is exactly what varies between 19 and 21 items in production.Executed on the built assembly (the Notifications library targets plain
net10.0, so it runs here):The fixed cost is exactly countable: head = 2 (one header block, one field section — the lead fields are five to seven, never past ten), footer = 2 (the triage
actionsbutton, thecontextline; 1 without a triage URL), the ten fixed items = 33 (Diagnosis 2, Advice 2, T-SQL 2, Spike Peak 2, Queries At Spike 5, Top Cpu Queries 4, Top Spilling Queries 3, Tempdb Breakdown 3, Parameter Sensitive Queries 5, Regressed Queries 5), each incident 2. Blocks = 37 + 2×incidents; the seventh incident is the fifty-first block. No text object came within 2,400 characters of its ceiling — the 2,777-character body on the page is the Remediation T-SQL, which Slack never inlines (it renders the fixed pointer). The per-text-object limits never touched this failure mode.One caveat the numbers force: production's 18-versus-19 separator was a property of those pages' widths, not a rule. My 18-item fixture costs 53 blocks (its drill-downs are wider than whatever the delivered 18-item page carried), and a four-fact story with verbose drill-downs reaches the line with fewer items. So the budget is on blocks, computed from the rendered runs, not on any count upstream of them — and there is a test arm that pins the same 18 items landing either side of the line depending on width.
The fix: one discipline, not two
#3493 bounded the alert's prose; the analysis pages deliver no prose at all (
DeliverDetailText: false) and carry the whole finding as details. Rather than a second, detail-side contract to keep in step with the first, the details path now composes under #3493's discipline, reusing its machinery:BuildSlackPayloadcomposes the message in three runs, budgeted in the order of what is fixed: head (2), footer (1–2), then the details take what remains under 50 — less one block held for the prose when there is prose — and the prose packs last into exactly what is left. Before this change the prose'sMath.Max(1, …)floor was applied after the details had spent the budget, which is itself a fifty-first block when both halves are heavy; a test arm pins that case landing on exactly 50 with both omissions stated.AddSlackDetailBlocksrenders every detail whole, and if the total fits, appends the runs exactly as the old loop did. If not, it keeps the longest leading run that fits beside the omission item. Producers already order most-essential-first — the analysis path emits Diagnosis, Advice, the T-SQL pointer, then drill-downs, then incident fingerprints — so the finding survives and the incidents' tail goes. Nothing is skipped to fit a smaller item behind it: a reader who sees "Incident 5 of 11" and then the omission knows exactly where the message stopped. A body detail at the boundary is not dropped whole; it takes the blocks that remain and states its own line omission, because on an analysis page the body is the advice and a shortened advice beats a missing one.BuildContext's doc now says its order is load-bearing on Slack.*Omitted from this message*/N more details did not fit Slack's 50-block message limit: Incident 6 of 11; …; Incident 11 of 11 - see email or in-app Alert Details for the full text.Headings are a detail's identity, so the reader knows what to open the alert for; the list is capped at 1,000 characters so the omission section respects the ceiling it exists for. Its two-block cost is charged before any detail is kept, so it is always affordable when needed. The pointer text is now one constant shared by all three omission sites (prose lines, dropped details, cut fields), so they cannot drift.AddSlackProseSectionstakes its header as a parameter, so a detail body routes through the same splitter as the prose with*Heading*as its first-section header — same packing, same continuation marker, same omission wording. Field texts are capped at Slack's 2,000-character field limit with the cut stated inline (… (N more characters - see email or in-app Alert Details for the full text)), two-pass so the note's own digits cannot push it over. Headings are bounded at 500 so the splitter's capacity is always positive. Plainly: no observed page has come within reach of any of these; the three lost pages would have been lost with them alone.A page that fits is byte-identical. The 3- and 6-incident fixtures serialize to the same bytes before and after (diffed against a build of
origin/dev), and the test restates the pre-#3612 loop verbatim as its oracle. Every engine alert and every analysis page that ever delivered lives on this path.After the fix every shape from 7 to 15 incidents lands at 49 blocks: the ten fixed items, five incidents, the omission item, head and footer.
Teams and email
Teams is an O365 MessageCard, not an Adaptive Card. The format has no section-count limit (the reference's "don't include more than 10 sections" is a readability guideline, not a rejection); the connector's one hard ceiling is the ~28 KB payload. Measured: the lost 21-item page renders at 16.6 KB, the heaviest producible shape (25 items, fifteen incidents) at 17.4 KB — about 210 bytes per incident, so the cap is some fifty incidents past anything the producers can emit. No Teams change ships; the margin is documented at the Teams builder and pinned by a test so a producer that widens the page fails there rather than in a lost delivery. Email renders the same details as HTML with no such limit; nothing to do.
Tests
Lite.Tests/SlackDetailsSizeTests.cs, thirteen arms beside #3493'sSlackProseSplitTests.cs: the fitting shapes byte-for-byte against the restated old loop; the failing shapes (7 — the first past the line — and 9/10/11 — the three production pages — and 15) inside every ceiling with the finding whole, incidents kept as an ordered prefix, and the omission naming the count and every dropped heading, each arm guarded by an assertion that the old rendering exceeds 50 so it cannot pass vacuously; blocks-not-count (the same 18 items fitting or not by width); a body at the boundary taking what remains; heavy prose plus heavy details landing on exactly 50; body split reassembly under its heading; the field cut's arithmetic (kept + omitted = original); the Teams cap pin.These cannot run in Lite.Tests on this Mac (
net10.0-windows), so I compiled the same test file — plusSlackProseSplitTests,AlertIncidentRenderTests,TriageLinkTests,WebhookPayloadBrandingTestsandCustomAlertRenderTests— into a throwawaynet10.0xunit project namedLite.Testsagainst the built Notifications assembly: 66 green. Lite.Tests itself builds under-c Release -p:EnableWindowsTargeting=truewith 0 warnings. CI is the first Windows execution.Evidence credit: the monitoring seat's
config_alert_logread (tick record 20260918T164232Z and the follow-up length/count discriminator) is what re-aimed this lane from text length to block count before any code was written. Related: #3493 / #3494 (the prose half of this discipline, and the tail-side note that predicted this), #3466.Closes #3612