Skip to content

fix(notify)!: make the Slack silence control legible, public, and durable - #559

Merged
Smana merged 18 commits into
mainfrom
fix/silence-control-ux
Aug 26, 2026
Merged

fix(notify)!: make the Slack silence control legible, public, and durable#559
Smana merged 18 commits into
mainfrom
fix/silence-control-ux

Conversation

@Smana

@Smana Smana commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Summary

The 🔕 silence control shipped working but unusable. First live use on 2026-08-26 exposed three separate problems, all in the same click:

Before After
The control A bare ··· — Slack's overflow element takes no text, so 🔕 and the word "Silence" appeared only inside the menu, after you had clicked something you could not identify A static_select rendering a visible 🔕 Silence… placeholder
The acknowledgement Ephemeral only — slackResponseBody marks any non-replacing answer response_type: ephemeral, so "🔕 Silenced by @x" reached the clicker alone. A colleague could open the same finding minutes later and start investigating something already handled Two answers, not one: the card is rebuilt in place through response_url so the marker is public, and the private SilenceAck still reaches the clicker
The card Never touched — a handled finding was byte-identical to an unhandled one in scrollback Control removed, a context-block marker appended naming who silenced it and until when

👍/👎 are deliberately kept on the silenced card: a 👎 re-arms a silenced trigger, and SilenceAck names that as the escape hatch.

Linked issue

n/a — found in live use.

The load-bearing half is the refusal

The rebuilt card is posted with replace_original: true, which overwrites the Slack message. A rebuild that guessed would replace the investigation — the finding, its evidence, its next steps — with a lone "🔕 Silenced by …" line, in exchange for a marker about the thing it just destroyed.

A naive version was written first, to watch it happen:

  • with no blocks in the payload it returned a card consisting only of the marker;
  • with an actions block holding the control alone it returned elements: [], which Slack rejects outright.

Both now return false and the caller falls back to today's ephemeral note. The card is worth more than the marker.

Two orderings are load-bearing, both watched failing first:

  1. The rebuild's refusal is honoured. Wired naively it posted {"replace_original": true, "blocks": null} — which tells Slack to overwrite the message with nothing.
  2. silenced/marker are set only after s.silence.Silence returns nil. Set before, a failing ledger write still stamped "🔕 Silenced by @bob until … · 48h" on the card while storing nothing — the channel told the incident was suppressed while RunLore went on investigating it.

Notable details

  • No migration, and none is performed. Slack delivers the chosen option at actions[0].selected_option.value for a static_select exactly as it did for an overflow, so cards already in scrollback stay clickable. That is the claim behind not writing dual-shape parsing, so it is pinned by TestSilenceReadsAStaticSelectPayload rather than merely asserted in review.
  • chat.update would have needed a new scope. response_url was already wired and already SSRF-guarded; the interaction payload already echoes message.blocks back.
  • message.blocks is decoded as raw maps. Block Kit has dozens of element types and a typed decode would drop the fields it did not know about — straight out of a card about to be posted back verbatim.
  • Rewriting is keyed on the block type being actions, not on the presence of an elements array, because a context block carries one too.
  • The card's top-level text is carried through. Slack clears every field the replacing payload omits, so the fallback line notify.fallbackText builds for push notifications and block-less clients was silently dropped — invisible on screen, which is exactly what makes it worth a test: the card still looked right while every later notification for that message, and every screen reader, got nothing.
  • updateSlack's validate-and-POST tail becomes postSlackResponse, shared with the new updateSlackBlocks, so the response_url guard has exactly one implementation.

Config: the window bounds stop blaming Slack

Both window-count errors explained themselves with Slack's overflow element ("at most 5 options", "requires at least 2"), each promising invalid_blocks and an undelivered finding. True while the control was an overflow; a static_select accepts a select with one option and with six.

The bounds themselves are unchanged — they were always the right numbers for the wrong stated reason. The 🔕 menu is a quick choice on a card someone is reading at 3am: one option is a button wearing a menu, six is a form. The messages now say that. TestSilenceWindowBoundsDoNotBlameSlack pins the absence, since a stale justification is exactly the kind of thing that survives every future edit unnoticed.

No config key changes meaning — no ! needed.

Also in here

  • test(outcome): a time bomb that fired on a clean tree. Two compaction silence tests recorded a 24h silence at a hardcoded 2026-08-25 13:00 UTC, then reloaded the ledger. Ledger.New and the checkpoint writer both call pruneLapsedSilencesLocked(time.Now()), so from 2026-08-26 13:00 UTC onward the replay legitimately dropped the silence and the assertions failed — 24h after the tests were written, with no code change. TestSilenceSurvivesCompaction in the same file already anchors to time.Now(); these two just missed the convention. Only the tests that reload are affected.
  • Docs. The Slack page called the control "a 🔕 overflow menu" and attributed the 2–5 bound to a Slack limit a reader would go looking for and not find; configuration.md, the chart comment and the Matrix page carried the same claim. The Slack page also gains the paragraph the feature had no documentation for at all: what a click actually does to the card.

Review pass on the branch

/code-review + /simplify were run over the series. Two defects it found are fixed in 1e8647ae; the quality cleanups are in ddb40926.

The acknowledgement was built and thrown away. msg = thread.SilenceAck(…) is assembled on the success path, but the card rewrite returned before anything posted it. The clicker got only the marker — never "⚠️ RunLore will NOT investigate this incident … no model call, no notification, no record", and never the escape hatches this deployment has. That last part is the whole of #556's honesty fix, and it had become reachable only on the blocks-missing fallback: the feedback_buttons: false operator who most needs telling was precisely the one no longer told. response_url takes five answers; the success path now sends both.

The marker's mention was not a mention. It formatted "@%s" with the username, and a bare @bob inside a Block Kit mrkdwn element renders as literal text — link_names is a chat.postMessage option with no effect on blocks. <@U9> is the one form Slack linkifies there, and p.User.ID was already in scope. SilenceMarker now takes the ref verbatim so the transport owns its own mention syntax — the same split as thread.RenderReply taking an escaper.

Both were watched failing first. The mention test reads the decoded payload, because encoding/json escapes < and > on the wire and an assertion over a re-marshalled dump would fail a correct mention and pass a broken one.

The rest of the review, fixed in 4ae089a5

Every remaining finding descended from one misplacement: rewriting the card put Block Kit construction into internal/server, which may not import internal/notify. The renderer's own helpers were on the far side of that line, and three defects are what "could not call them" looked like.

internal/slackcard is a new leaf package holding what the card's builder and its rewriter both need — action ids, the block_id encoding, EscapeMrkdwn, Date, UserMention, and the rewrite walk. The layering rule survives (server still does not import notify) but stops being paid for in duplication: the action ids were bare literals in both packages, and the block_id prefix existed twice behind a test that parsed the other package's source to compare them. That test is deleted — the compiler checks it now.

With Date reachable, the marker's expiry is a Slack date token instead of a timestamp in the pod's timezone: correct-looking to whoever wrote it, useless to a colleague in another zone. SilenceMarker now takes the ref and the time already rendered, so the transport owns how a person and an instant are drawn — the same split RenderReply makes by taking an escaper.

The rewrite refuses when it removed nothing. It used to append on the strength of the card being non-empty, so a click on an already-rewritten card — a second engineer on a stale view, or one racing the first rewrite — stacked a second marker and reported two different windows for one finding. It also carried block_id: "sil:<key>" onto the block whose control it had just deleted, breaking the iff-rule the renderer pins.

A failed mark is now told to the clicker. postSlackResponse returned nothing, so a refused rewrite or a 500 was a log line and otherwise invisible: silence recorded, channel none the wiser, and the one person who could say so certain it had been announced. Both failure modes append SilenceCardUnmarked to the ack.

The client timeout drops 10s → 1.2s per post. WriteHeader only records the status — the bytes leave when the handler returns — so these posts sit inside Slack's 3-second interaction deadline, and a silence now sends two. 10s could blow that budget on one slow post, which Slack answers with an operation_timeout shown to the clicker plus a retry of the whole interaction.

Smaller, same review: postSlackResponse tolerates a nil client rather than panicking inside a handler for a &Server{} built by struct literal (which this package's tests already do); the silenced bool is gone, since an empty marker was already the same fact; and the test fixtures quote through encoding/json instead of splicing, so a fallback containing a quote stops producing a 400 that reads as a rejected card.

Every new guard was mutation-tested — dropping the !removed check, scoping the block_id deletion to the loop-carried flag, and removing the disclosure each fail the tests written for them.

The window bounds now sit at one scope

The 2-to-5 preset bound was enforced at two scopes: the floor only under notify.slack.silence_button, the ceiling always. That fit while the numbers were Slack's — an overflow element rejected the whole message at either breach. It stopped fitting when both were reclassified as a UX judgement about the rendered dropdown, and the reclassification reached the wording without reaching the enforcement.

A rule about how many options make a usable menu cannot apply to a deployment that renders no menu. Matrix silences on a bare 🔕 reaction, which reads windows[0] and nothing else — so the second preset is exactly as unreachable there as the sixth. The ceiling's defence (it costs Matrix nothing it could reach anyway) was equally true of the floor, which is the tell that one rule was pretending to be two. Both ends now sit under silence_button, in one check with one message.

Scoping only ever accepts configs that were previously rejected, so no working deployment breaks on upgrade.

A second review pass, on the new code

Reviewing the commits above found three more, fixed in 11548859:

  • postSlackResponse closed the response body without reading it. net/http only returns a connection to the idle pool once its body hits EOF, so every response_url POST was killing its connection and redoing DNS + TCP + TLS. Survivable at 10s; at 1.2s it is the difference between a generous bound and a coin flip — a silence makes two sequential posts inside Slack's 3s budget, and a cold handshake on each is exactly how one times out after Slack already applied it, telling the clicker their card went unmarked when it did not. It also makes the slackHTTP comment true, which reasoned about a shared pool nothing from this call site was entering.
  • The two refusals were reported as the same thing. Silenced returned a bare false whether the card carried no marker or someone else's, and the handler said "the channel cannot tell this finding is handled" for both. On an already-rewritten card that is plainly false — the channel sees a marker, it is just the earlier window, and the ledger's latest-wins fold means card and suppression now disagree. The clicker was steered into a redundant note while the real problem went unsaid. SilenceOutcome names the three cases; an already-rewritten card now gets SilenceCardStale.
  • A third stale reference to the guard test deleted two commits earlier, sitting directly above the constant it described.

How was it verified?

Full gate, clean:

go build ./...      ✓
go vet ./...        ✓
go test ./...       ✓
gofmt -l .          ✓ (prints nothing)
hack/lint.sh        ✓ 0 issues

Plus go test -race on every touched package (server, notify, thread, config, outcome) — the silence path posts over HTTP from a pooled client now, so the races are worth ruling out explicitly.

The card golden moves, so hack/check-screenshots-fresh.sh's acknowledgement is re-pointed at the new digest. This is the one card change of the series a reader of the shipped screenshots would actually notice; retaking them still needs the live workspace whose credentials sit outside this repo.

Breaking change

BREAKING CHANGE: notify.silence.windows may now list more than 5 entries when
notify.slack.silence_button is off. Nothing that validated before stops validating —
the bound is only relaxed — but a Matrix-only deployment that adopts a longer preset
list will fail validation at startup if slack.silence_button is later enabled, naming
notify.silence.windows and the flag to turn off.

Checklist

  • go build ./... passes
  • go vet ./... passes
  • go test ./... passes (-race on all touched packages)
  • gofmt -l . prints nothing
  • golangci-lint run ./... reports 0 issues
  • Test added first (TDD) — both refusal cases and both orderings were watched failing before the implementation
  • Docs updated — Slack, Matrix, configuration pages and the chart comment
  • Respects read-only-first: no new cluster writes
  • Title carries ! and the body carries a BREAKING CHANGE: footer — the window-bound scope changed

Smana added 16 commits August 26, 2026 16:10
The 🔕 control shipped working but unusable: Slack's overflow element
takes no label so it renders as a bare `···`, the confirmation is
ephemeral so only the clicker sees it, and the card is never touched so
scrollback cannot tell a handled finding from an unhandled one.

Spec swaps the element for a static_select with a placeholder, and
rewrites the card in place on click so the acknowledgement is public and
durable in one stroke.

The hard invariant it records: replace_original today means "overwrite
with plain text", so a naive rewrite would blank the investigation. The
rebuild is conditional on usable blocks and falls back to the current
ephemeral note. The card is worth more than the marker.
…time

Both tests recorded a 24h silence at a hardcoded 2026-08-25 13:00 UTC and then
reloaded the ledger. Ledger.New and the checkpoint writer both call
pruneLapsedSilencesLocked(time.Now()), so from 2026-08-26 13:00 UTC onward the
replay legitimately dropped the silence and the assertions failed — a time bomb
that fired 24h after the tests were written, on a clean tree with no code change.

TestSilenceSurvivesCompaction in the same file already anchors to time.Now() for
exactly this reason; these two just missed the convention. Only the tests that
RELOAD are affected: the prune runs at load and at checkpoint, never inline, so
the sibling tests that keep one ledger in memory are correct as written.
Slack's overflow element takes no text, so the 🔕 control drew as a bare "···"
beside 👍 Accurate / 👎 Off-base. The 🔕 and the word "Silence" appeared only
inside the menu — after you had clicked something you could not identify. That
was the first thing the first live use of the feature exposed, 2026-08-26.

A static_select is the one actions-block element that carries both a visible
label and a menu, so the control now renders with a "🔕 Silence…" placeholder.
Buttons-per-window would scale to seven controls at five presets, and a
button-plus-modal would need views.open, a trigger_id and a submission handler —
a new failure surface for a cosmetic win.

No migration is needed and none is performed: Slack delivers the chosen option at
actions[0].selected_option.value for a static_select exactly as it did for an
overflow, so cards already in scrollback stay clickable. That is the load-bearing
claim behind not writing dual-shape parsing, so it is now pinned by
TestSilenceReadsAStaticSelectPayload rather than merely asserted in review.

The 2-option floor stays but stops being a Slack limit: Slack accepts a select
with one option, and a one-entry dropdown is simply a button wearing a menu.

The card golden moves, so hack/check-screenshots-fresh.sh's acknowledgement is
re-pointed at the new digest. This is the one card change of the four so far that
a reader of the shipped screenshots would actually notice; retaking them still
needs the live workspace whose credentials sit outside this repo.
Both window-count errors explained themselves with Slack's overflow element:
"at most 5 options", "requires at least 2", each promising invalid_blocks and an
undelivered finding. That was true while the 🔕 control was an overflow. It is a
static_select now, and Slack accepts a select with one option and with six — so
the errors sent the reader looking for a Slack limit that is no longer there.

The bounds themselves stay, because they were always the right numbers for the
wrong stated reason: the 🔕 menu is a quick choice on a card someone is reading
at 3am, so one option is a button wearing a menu and six is a form. The messages
now say that instead. The floor stays scoped to slack.silence_button and the
ceiling stays unconditional, unchanged — those scopes never depended on the
element type.

TestSilenceWindowBoundsDoNotBlameSlack pins the absence, since a stale
justification is exactly the kind of thing that survives every future edit
unnoticed. The existing table's base config is extracted to silenceTestConfig so
both tests build "a minimal valid silence config" from one definition.
The Slack card is never touched when someone silences an investigation, so a
handled finding is byte-identical to an unhandled one in scrollback. Stamping the
card needs a line, and SilenceAck is the wrong line: it is three sentences aimed
at the person who just clicked, explaining what they have switched off.

SilenceMarker addresses the other reader — a colleague scanning the channel a day
later, who needs only "has anyone already dealt with this?". It stays on one line
because it renders in a context block, and it @-prefixes the user so Slack draws
a mention.

Shared here beside SilenceAck rather than spelled in internal/notify or
internal/server for the same reason ShortDuration is: one wording, one place.
silencedCard removes the 🔕 control from the card the click came from and appends
a marker context block, so a handled finding stops looking identical to an
unhandled one. 👍/👎 are kept: a 👎 re-arms a silenced trigger, and SilenceAck
names that as the escape hatch.

The refusal is the load-bearing half. The rebuilt card is posted with
replace_original: true, which overwrites the Slack message, so a rebuild that
guessed would replace the investigation with a lone "🔕 Silenced by …" line and
lose the finding it was marking. A naive version was written first to see exactly
that: with no blocks in the payload it returned a card consisting only of the
marker, and with an actions block holding the control alone it returned
`elements: []`, which Slack rejects outright. Both now return false, and the
caller falls back to today's ephemeral note.

Rewriting is keyed on the block type being "actions" rather than on the presence
of an "elements" array, because a context block carries one too.

message.blocks is decoded as raw maps: Block Kit has dozens of element types and
a typed decode would drop the fields it did not know about — straight out of a
card about to be posted back verbatim.
A silence recorded fine but told almost nobody. slackResponseBody marks any
non-replacing answer response_type: ephemeral, and silence went down the feedback
path, so "🔕 Silenced by @x" reached the clicker alone — a colleague could open
the same finding minutes later and start investigating something already handled.
The card itself was never touched, so scrollback could not distinguish a handled
finding from an unhandled one at all.

Both are now fixed by the same move: the card is rebuilt in place through the
response_url that was already wired and already SSRF-guarded, so the marker is
public by construction and survives in scrollback. chat.update would have needed
a new scope; the interaction payload already echoes message.blocks back.

Two orderings are load-bearing and both were watched failing first:

  - The rebuild's refusal is honoured. Wired naively it posted
    {"replace_original": true, "blocks": null} for a payload carrying no blocks,
    which tells Slack to overwrite the message with nothing. The finding, its
    evidence and its next steps, gone, in exchange for a marker about them.
  - silenced/marker are set only after s.silence.Silence returns nil. Set before,
    a failing ledger write still stamped "🔕 Silenced by @bob until … · 48h" on
    the card while storing nothing — the channel told the incident was suppressed
    while RunLore went on investigating it.

updateSlack's validate-and-POST tail becomes postSlackResponse, shared with the
new updateSlackBlocks, so the response_url guard has exactly one implementation.
The client it uses moves onto the Server: it is worth pooling, and substituting a
transport is the only way a test can see what was posted — the guard accepts no
host but *.slack.com, so an httptest server is refused before any request.
The Slack page called the control "a 🔕 overflow menu" and attributed the 2-5
window bound to Slack answering a non-conforming block by rejecting the whole
message. Neither is true since the control became a labelled static_select: the
bound is a UX judgement now, and a reader chasing the stated Slack limit would
not find one. configuration.md and the chart comment carried the same claim, and
the Matrix page described its reaction as the equivalent of Slack's overflow.

The Slack page also gains the paragraph the feature had no documentation for at
all: what a click DOES to the card — control removed, a public line naming who
silenced it and until when, 👍/👎 kept so the escape hatch survives, and the
untouched-card fallback when Slack sends no blocks back.
The rewrite posted replace_original with blocks alone. Slack clears every field
the replacing payload omits, so the card's top-level "text" — the one-line
summary notify.fallbackText builds for push notifications and block-less clients
— was silently dropped.

Invisible on screen, which is what makes it worth a test: the card still looked
right in the channel while every later notification for that message, and every
screen reader, got nothing. The interaction payload echoes the field back, so it
is carried over verbatim.
…t exists

Five cleanups from a quality pass over the branch, no behaviour change.

silencedCard's `len(blocks) == 0` guard was dead: with no blocks the loop body
never runs, `out` stays empty, and the `len(out) == 0` check at the bottom
already returns the same (nil, false). Two exits for one condition, and a reader
had to compare them to find they agreed. Its hand-rolled map copy becomes
maps.Clone, which is what internal/source/grafana already uses for the same
shallow-copy-then-mutate shape.

The test scaffolding had grown three constructors where one belongs — two were
one-line pass-throughs — plus a slackCapture struct carrying two values that are
compile-time constants at every call site. And three senders, of which
sendSilenceCardWithText duplicated sendSilence's sign-and-serve tail byte for
byte. The signing boilerplate now has one copy in postSignedInteraction, so a
change to how an interaction is authenticated lands in one place.

TestSilenceControlIsLabelledNotABareOverflow walked the block shape by hand to
find the control, which is exactly what findSilenceControl exists for — in the
one file whose stated point is that callers should not care how the control is
drawn. Its "no element is an overflow" sweep goes with it: 👍/👎 are buttons, so
the control is the only element that could regress, and the static_select
assertion three lines down already says so.

Last, a comment that asserted something untrue. slackHTTP was justified partly
as "frequent enough to be worth a pooled connection", but httpx.SecureClient
leaves Transport nil, so every client it ever returned already shared
http.DefaultTransport's process-wide idle pool. Hoisting it saves one struct
allocation per interaction and nothing else; the test seam is the real reason,
and the comment now says only that — plus why the pooling story is wrong, so it
does not get re-added.
…tion

Two defects in the silence rewrite, both found by review of the branch.

The acknowledgement was being built and thrown away. msg = thread.SilenceAck(…)
is assembled on the success path, but the card rewrite returned before anything
posted it, so the only answer a clicker got was the public marker. The marker
says who and until when; it does not say that RunLore will NOT investigate this
incident — no model call, no notification, no record — and it does not name the
escape hatches this deployment actually has. That last part is the whole of the
#556 honesty fix, including its startup warning, and it had become reachable
only on the blocks-missing fallback: exactly the feedback_buttons:false operator
who most needs telling was the one no longer told.

The two are different answers to different readers, and response_url takes five,
so the success path now sends both — the rebuilt card, then the ephemeral ack.
Only the `return` moved; the refusal path is unchanged and still degrades to the
ack alone.

The marker's mention was not a mention. It formatted "@%s" with the username,
and a bare @bob inside a Block Kit mrkdwn element renders as literal text —
link_names is a chat.postMessage option with no effect on blocks. So the marker
named the silencer without notifying them or linking their profile, which is
most of the reason it names them. <@u9> is the one form Slack linkifies there
and p.User.ID was already in scope at the call site.

SilenceMarker now takes the ref verbatim rather than a name it prefixes: the
transport owns its own mention syntax, the same split as thread.RenderReply
taking an escaper. That also stops internal/thread claiming, as its comment did,
that a leading @ is enough.

Both were watched failing first. TestSilenceStillWarnsTheClicker asserts the
card rewrite and the ack are both posted; TestSilenceMarkerMentionsTheUserByID
reads the DECODED payload, because encoding/json escapes < and > on the wire and
an assertion over a re-marshalled dump would fail a correct mention and pass a
broken one. captureSlackResponse records every post instead of the last, since
one answer per interaction is no longer the shape.

slack.md said the public line was visible to the channel "rather than to the
clicker alone", which read as the marker replacing the private ack. It is in
addition to it, and the page now says so.
… silence

Everything here descends from one misplacement: rewriting the card put Block Kit
construction into internal/server, which may not import internal/notify. The
renderer's own helpers were on the far side of that line, so the rewrite could
not call them — and three separate defects are what "could not call them" looked
like in production.

internal/slackcard is a leaf package holding what the card's BUILDER and its
REWRITER both need: the action ids, the block_id encoding, EscapeMrkdwn, Date,
UserMention, and the rewrite walk itself. The layering rule is intact — server
still does not import notify — but the duplication it used to cost is gone. The
action ids were bare literals in both packages; the block_id prefix existed twice
behind a test that PARSED the other package's source to compare them. That test
is deleted: the compiler checks it now.

With Date reachable, the marker's expiry is a Slack date token like every other
time on the card, instead of a timestamp in whatever zone the pod runs in —
correct-looking to the author and useless to a colleague in another timezone.
SilenceMarker takes the user ref and the time already rendered, so the transport
owns how a person and an instant are drawn and thread owns only the word order.
That is the same split RenderReply makes by taking an escaper, and it is here
because thread got both halves wrong while it rendered them itself.

The rewrite refuses when it removed nothing. It used to append the marker on the
strength of the card being non-empty, so a click on an already-rewritten card —
a second engineer on a stale view, or a click that raced the first rewrite —
stacked a second marker under the first and reported two different windows for
one finding. It also carried block_id "sil:<key>" onto the block whose control it
had just deleted, breaking the iff-rule the renderer holds and pins.

And a failure to mark the card is now TOLD to the clicker. postSlackResponse
returned nothing, so a refused rewrite or a 500 from Slack was a log line and
otherwise invisible: the silence recorded, the channel none the wiser, and the
one person who could say so walking away certain it had been announced. That is
the "a colleague investigates something already handled" defect the marker exists
to prevent, restored silently. Both ways of failing now append SilenceCardUnmarked
to the acknowledgement.

The client timeout drops from 10s to 1.2s per post. WriteHeader only records the
status — the bytes leave when the handler returns — so these posts sit inside
Slack's 3-SECOND interaction deadline, and a silence now sends two of them. 10s
could blow that budget on one slow post, which Slack answers with an
operation_timeout shown to the clicker and a retry of the whole interaction.

Smaller, from the same review: postSlackResponse tolerates a nil client rather
than panicking inside a handler for a &Server{} built by a struct literal, which
this package's tests already do; the `silenced` bool is gone, since an empty
marker was already the same fact and two variables could only disagree; and the
test fixtures quote through encoding/json instead of splicing, so a fallback
containing a quote stops silently producing a 400 that reads as a rejected card.

Every new guard was mutation-tested: dropping the !removed check, scoping the
block_id deletion to the loop-carried flag, and removing the unmarked disclosure
each fail the tests written for them.
server.go pointed at notify.silenceBlockIDPrefix, which no longer exists, and the
implementation plan still described the constant-duplication rule the shared
package replaced — naming a guard test that is deleted. The plan keeps its
original constraint as the record of what was planned, with a note on what the
implementation did instead and why.
…ribes

The 2-to-5 preset bound was enforced at two different scopes: the floor only when
notify.slack.silence_button was on, the ceiling always. That made sense while the
numbers were Slack's — an overflow element rejected the whole message at either
breach. It stopped making sense when both were reclassified as a UX judgement
about the rendered dropdown, and the reclassification reached the wording without
reaching the enforcement.

A rule about how many options make a usable menu cannot apply to a deployment
that renders no menu. Matrix silences on a bare 🔕 reaction, which carries no
duration and reads windows[0] and nothing else, so the second preset is exactly
as unreachable there as the sixth. The ceiling's defence — that it costs a
Matrix-only deployment nothing it could reach anyway — was equally true of the
floor, which is the tell that one rule was pretending to be two.

Both ends now sit under silence_button, in one check with one message.

The cost, stated rather than discovered: a Matrix-only config may now hold a
preset list Slack would refuse, and turning silence_button on later fails at
startup instead of silently drawing a bad menu. That is the better failure — loud,
at boot, naming the key and the flag — and it is what the error text points at.

Scoping only ever ACCEPTS configs that were previously rejected, so no working
deployment can break on upgrade. The mirror test of the existing "a single preset
is fine for Matrix alone" pins the other end, and both fail if the condition is
removed.

BREAKING CHANGE: notify.silence.windows may now list more than 5 entries when
notify.slack.silence_button is off. Nothing that validated before stops
validating — the bound is only relaxed — but a Matrix-only deployment that adopts
a longer preset list will fail validation at startup if slack.silence_button is
later enabled, naming notify.silence.windows and the flag to turn off.
…le card

Three findings from reviewing the previous commits.

postSlackResponse closed the response body without reading it. net/http only
returns a connection to the idle pool once its body reaches EOF, so every
response_url POST was marking its connection dead and redoing DNS, TCP and TLS.
That was survivable under a 10s timeout. Under 1.2s it is the difference between
a generous bound and a coin flip: a silence makes two sequential posts inside
Slack's 3s interaction budget, and a cold handshake on each is exactly how one
times out AFTER Slack already applied it — telling the clicker their card went
unmarked when it did not. The drain is bounded; the answer is "ok", and a body
worth streaming would be a Slack bug, not something to spend the click on.

It also makes the slackHTTP comment true. It reasoned that clients from
httpx.SecureClient already share http.DefaultTransport's pool — correct, and
irrelevant while nothing from this call site ever entered it.

The bigger one: the two refusals were reported as the same thing. Silenced
returned a bare false whether the card carried NO marker or someone ELSE's, and
the handler said "the channel cannot tell this finding is handled" for both. On
an already-rewritten card that is plainly false — the channel can see a marker,
it is just the earlier window, and the ledger's latest-wins fold means the card
and the suppression now disagree. The clicker was steered into writing a
redundant "someone might pick this up" note while the real problem went unsaid.
The log line was wrong the same way, sending an operator to hunt a payload
problem that did not exist.

SilenceOutcome names the three cases and the handler answers each: a failed POST
and an unusable payload keep SilenceCardUnmarked, an already-rewritten card gets
SilenceCardStale, which says the card shows the earlier window rather than
claiming nothing is shown. It does not repeat the new window — the ack it is
appended to has just stated it.

Last, a third stale reference to the guard test deleted two commits ago, sitting
directly above the constant it describes and naming the test as the enforcement.
The previous commit caught the plan doc and server.go and missed this one.
@Smana Smana changed the title fix(notify): make the Slack silence control legible, public, and durable fix(notify)!: make the Slack silence control legible, public, and durable Aug 26, 2026
Smana added 2 commits August 26, 2026 21:58
Review of the previous two commits. No behaviour change except the enum's zero
value, which nothing reads today.

The doc comments had drifted the moment Silenced stopped returning a bool: one
still said "it reports false", another headed a two-item list with "Three things
make it refuse", and updateSlackBlocks still pointed at "Silenced's false return".
Worse, the note explaining the ordering said stale is "a card that SURVIVED and
kept its control" — the exact opposite of the branch it describes, which fires
when there was no control to remove. Someone debugging that path would have gone
looking for a card still showing the 🔕 menu.

The comment above the handler switch described two arms and called both "an
unmarked card", written before the third existed. An already-rewritten card IS
marked; that is why it gets its own disclosure twelve lines below. The paragraph
justifying the switch contradicted the switch.

SilenceOutcome gains String(), because six assertions print it with %v and
"outcome = 1, want 2" is a poor failure message for a type whose whole purpose is
that its two refusals are not interchangeable. SilenceNoUsableCard moves to iota
0: an outcome nobody set should be the one that discloses conservatively, not the
one that suppresses every disclosure. Every sibling enum in this repo puts the
neutral value first.

The switch gains a default. .golangci.yml does not enable exhaustive, so a fourth
outcome added later would fall through telling the clicker nothing about their
card — the failure the switch exists to prevent, restored by omission.

Tests: the ceiling case asserted "notify.silence.windows", a substring every
windows error contains, so it would have passed on the wrong error — its mirror
had already been updated to "2 to 5". The happy-path test pinned only one of the
two cry-wolf strings; the second is now pinned too. The Matrix-only setup was
written out three times and is now matrixOnly().

Last, the error's entry/entries branch was pinned by nothing and is gone —
reworded so no pluralisation exists rather than tested into place.
The comment claimed a bare Close would make "every response_url POST redo DNS +
TCP + TLS", and called that the difference between the 1.2s timeout being a
generous bound and a coin flip. Measured against a local server, it is not.

  body    bare Close    drained
  2 B     1 dial        1 dial
  1 KB    1 dial        1 dial
  4 KB    2 dials       1 dial
  64 KB   2 dials       2 dials

net/http has already buffered a small body by the time Close runs, so the
connection goes back to the pool either way below roughly 2 KB. Slack answers a
response_url POST with "ok". On the path this function actually takes, draining
changes nothing — and past the 4 KB cap it changes nothing either, because the
copy stops before EOF.

The drain stays: it is free on the path that already reached EOF, and it is
correct for the middle band where it does help. What was wrong was the
justification, and this repo has spent this whole branch removing comments that
assert mechanisms the code does not have — including one in this same function
that claimed a pooling benefit from hoisting the client. Replacing it with a
second false claim in the opposite direction is not an improvement.

The numbers are in the comment so the next reader does not have to re-derive
them, or trust them.
@Smana
Smana merged commit b68a503 into main Aug 26, 2026
5 checks passed
@Smana
Smana deleted the fix/silence-control-ux branch August 26, 2026 20:33
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.

1 participant