Skip to content

Slack-origin dispatches

A conversational Slack agent can sit in front of this dispatcher. Its ingress classifies inbound Slack events by event class — never by message content — handles the conversational ones itself (mentions, DMs, thread replies), and re-dispatches the batch-shaped ones here over the ordinary HMAC dispatch route, carrying the Slack origin context. No event class reaches both sides.

This document is the dispatcher’s half of that split: the envelope a slack-origin dispatch is held to, enforced here, on this deploy, against this deploy’s config. The ingress is expected to send only what the envelope allows; the dispatcher does not depend on it having done so.

Implementation: ../src/slack-origin.ts (policy), ../src/slack-notify.ts (the verdict callback and the notice), ../src/routes/dispatch.ts § 4.5 / § 5.5 (enforcement points).

Everything up to § The verdict callback is about runs that came from Slack. § The notice covers the other direction — a scheduled run with something to say and no thread to say it in.

A slack-origin dispatch, round trip Sequence diagram. 5 participants, 11 messages. Participants: Slack (S), Slack ingress (I), Dispatcher (D), RunWorkflow (W), GitHub (G). 1. Slack → Slack ingress: event Note over Slack ingress: classify by event class, conversational ones stay here 2. Slack ingress → Dispatcher: POST /v1/dispatch/:run, HMAC, Idempotency-Key, source alt refused by the envelope: 3. Dispatcher --> Slack ingress: 403 with error, message, run 4. Slack ingress → Slack: post message in thread else accepted: 5. Dispatcher → RunWorkflow: create instance, source in params 6. Dispatcher --> Slack ingress: 202 with executionId 7. Slack ingress → Slack: ack in thread 8. RunWorkflow → GitHub: check-run, in_progress 9. RunWorkflow → GitHub: complete the check-run 10. RunWorkflow → Slack ingress: POST verdict, signed with k_verdict 11. Slack ingress → Slack: post verdict in thread, bot token altrefused by the envelopeaccepted SlackSlack Slack ingressSlack ingress DispatcherDispatcher RunWorkflowRunWorkflow GitHubGitHub classify by event class,conversational ones stay here event POST /v1/dispatch/:run,HMAC, Idempotency-Key,source 403 with error, message, run post message in thread create instance, source inparams 202 with executionId ack in thread check-run, in_progress complete the check-run POST verdict, signed withk_verdict post verdict in thread, bottoken
A slack-origin dispatch, round trip
Diagram source
sequenceDiagram
accTitle: A slack-origin dispatch, round trip
participant S as Slack
participant I as Slack ingress
participant D as Dispatcher
participant W as RunWorkflow
participant G as GitHub
S->>I: event
Note over I: classify by event class,<br/>conversational ones stay here
I->>D: POST /v1/dispatch/:run, HMAC, Idempotency-Key, source
alt refused by the envelope
D-->>I: 403 with error, message, run
I->>S: post message in thread
else accepted
D->>W: create instance, source in params
D-->>I: 202 with executionId
I->>S: ack in thread
W->>G: check-run, in_progress
W->>G: complete the check-run
W->>I: POST verdict, signed with k_verdict
I->>S: post verdict in thread, bot token
end

The ordinary body plus one optional block. Absent, nothing below applies and the GHA-Action path is unchanged in every respect.

{
"run": "spec-drift-pr",
"github": { "repo": "<owner>/<name>", "ref": "refs/heads/main", "sha": "<sha>" },
"inputs": { "firedAt": 1712345678000 },
"source": {
"kind": "slack",
"team_id": "T0…",
"channel": "C0…",
"thread_ts": "1712345678.123456", // optional — where the verdict lands
"user_id": "U0…", // optional — audit only, never authorization
"event_class": "slash_command" // optional — recorded, never trusted
}
}

Idempotency-Key is required on a slack-origin dispatch (the Slack event id is the natural value). Without it the route’s fallback id is {run}:{repo}:{sha12}, and the batch runs on the allowlist carry no per-request input — two “run the sweep” messages against one commit would fold onto a single execution and the second would answer 202 while nothing ran.

Checked in this order; the first refusal wins. Every refusal is 403 with { "error": "<code>", "message": "<prose>", "run": "<name>" }, and message is written to be posted verbatim into the thread — one branch for the caller, one sentence for the human.

Code When
slack_origin_context_required Signed with the Slack-scoped key but no source block (see below)
slack_origin_unconfigured No target repo pinned on this deploy — the batch path is off
credential_selection_not_permitted inputs names secrets, secretPrefix, roleArn, bedrockRoleArn, or env
approval_required The run pauses for a human decision (humanGate)
run_not_allowed_from_slack The run is not on this deploy’s Slack allowlist
payload_command_run The run takes a command from its dispatch payload
repo_not_pinned github.repo is not the pinned target
idempotency_key_required No Idempotency-Key header

The run half runs after the envelope decodes and before inputs decode; the inputs half runs after:

Where the slack-origin policy sits in the dispatch route Flowchart, top to bottom. 10 nodes, 13 edges. Signature verifies? → 401 [no]; → run lookup, JSON parse, envelope decode [yes, and which key] run lookup, JSON parse, envelope decode → Slack-scoped key without source? Slack-scoped key without source? → 403 error, message, run [yes]; → Has source? [no] Has source? → decode inputs against the run's schema [no]; → run half unconfigured, credential, approval, allowlist, payload command, repo pin, Idempotency-Key [yes] decode inputs against the run's schema → inputs half credential after defaults [with source]; → target gate, cooldown, dedup, create the Workflow [no source] run half unconfigured, credential, approval, allowlist, payload command, repo pin, Idempotency-Key → 403 error, message, run [first refusal]; → decode inputs against the run's schema [admitted] inputs half credential after defaults → 403 error, message, run [refused]; → target gate, cooldown, dedup, create the Workflow [admitted, secrets forced empty] no yes, and which key yes no no yes first refusal admitted with source no source refused admitted, secrets forcedempty Signature verifies? 401 run lookup, JSON parse,envelope decode Slack-scoped keywithout source? 403error, message, run Has source? decode inputsagainst the run's schema run halfunconfigured, credential,approval, allowlist, payloadcommand,repo pin, Idempotency-Key inputs halfcredential after defaults target gate, cooldown, dedup,create the Workflow
Where the slack-origin policy sits in the dispatch route
Diagram source
flowchart TB
accTitle: Where the slack-origin policy sits in the dispatch route
sig{"Signature verifies?"} -->|no| e401["**401**"]
sig -->|"yes, and which key"| body["run lookup, JSON parse,<br/>envelope decode"]
body --> scoped{"Slack-scoped key<br/>without `source`?"}
scoped -->|yes| e403["**403**<br/>error, message, run"]
scoped -->|no| has{"Has `source`?"}
has -->|no| inputs
has -->|yes| runhalf{"**run half**<br/>unconfigured, credential,<br/>approval, allowlist, payload command,<br/>repo pin, Idempotency-Key"}
runhalf -->|first refusal| e403
runhalf -->|admitted| inputs["decode `inputs`<br/>against the run's schema"]
inputs -->|"with `source`"| inhalf{"**inputs half**<br/>credential after defaults"}
inputs -->|"no `source`"| rest
inhalf -->|refused| e403
inhalf -->|"admitted, `secrets` forced empty"| rest["target gate, cooldown, dedup,<br/>create the Workflow"]
class e401 danger
class e403 danger
class rest ok

Naming a credential is refused against the raw payload, before schema decoding — decoding drops fields a run’s schema does not declare, so a secrets array smuggled onto a run that ignores it would otherwise vanish and the request would answer 202 to something forbidden. Refusing loudly is what an operator reading the response needs.

Past the refusal, the inputs handed to the Workflow are normalized: secrets: [] is forced for every run whose schema declares the field. The guarantee is a property of what runs, not of what was asked for.

SLACK_ORIGIN_RUNS in the policy module is the code-defined maximal set. CONFIG_KV slack-origin.runs (comma-separated) narrows it — the effective set is the intersection, so no KV write widens what a chat message reaches.

Payload-command runs are excluded structurally rather than from memory: payloadCommandInputs reads each run’s own inputs schema for command / args / …Command / …Script fields, so a run that grows one tomorrow drops out of the Slack path the moment it does. A registry-wide test asserts the allowlist is clean, so the drift surfaces at build time, not at dispatch time.

Also excluded, and why: runs where the caller picks the URL the container dials (deploy-smoke, playwright-e2e, email-otp-login, demo-reel, product-demo) — egress selection from a chat message is the same problem wearing a URL; and self-heal-pr, which is the escalation target of a diagnosis rather than something to ask for directly.

A run that declares humanGate hibernates in step.waitForEvent until a person approves or rejects. The batch path is fire-and-forget and has nowhere to ask, so such a run is refused before instantiation, with a message naming the run’s own humanGate.reason and pointing back at the conversational path. The alternative is the failure the rule exists to prevent: a Workflow parked for 72 hours while the thread shows nothing at all.

humanGate is declarative because a dispatcher cannot see a waitForEvent inside a run body. It marks a person deciding — not a run merely waiting on an external event (email-otp-login hibernates for a verification email, which arrives on its own).

SLACK_ORIGIN_HMAC_SECRET, when set, is a second dispatch key whose signature forces this policy whatever the body declares — the Slack ingress can then hold a credential incapable of dispatching outside the envelope, and enforcement stops depending on a caller declaring its own origin honestly. A body signed with it and carrying no source block is refused.

Unset (the default), the declared source selects the policy and the HMAC is the authentication. The scoped key is ignored when it equals HMAC_SECRET: one value under two names is not a scope, and silently treating every CI dispatch as slack-origin would break CI far from the mistake.

The check-run is the GitHub-side signal. For a slack-origin run the same finalize boundary also POSTs the verdict to the ingress’s callback endpoint, which posts it in-thread with the bot token. This dispatcher holds no Slack credential — giving a CI dispatcher workspace-write access to save one hop is how a token ends up somewhere nobody meant it to be.

POST <callback URL>
X-FlareDispatch-Signature: sha256=<hex over the raw JSON body bytes>

Same header, same raw-bytes canonicalization, same primitive as the inbound dispatch route — the receiver’s verification is the code it already wrote to sign dispatches, read backwards. The key is domain-separated so a callback signature can never be replayed as a dispatch signature:

k_verdict = HKDF-SHA256(ikm, salt = "", info = "flare-dispatch/slack-notify/v1")
ikm = SLACK_NOTIFY_SECRET, or HMAC_SECRET when that is unset

The notice derives from the same ikm under a different label — see § The notice.

Body: version, executionId, run, status (success | failure | skipped), repo, sha, the echoed origin, a ready-to-post text line, and optional checkRunName, logsUrl, detailsUrl, failureSummary. The receiver owns Slack formatting and may ignore text; it exists so a receiver that just wants to post something correct does not re-derive the wording.

Delivery is best-effort and bounded (10s): a missing URL, a non-2xx, or a timeout is a logged line, never a flip of a verdict the run already earned. The verdict is already in D1 and on the check-run.

Two 202 responses land no verdict later, by design, because they are already the answer: a dispatch inside a run’s cooldown window (skipped: "cooldown" + retryAfterSec) and a duplicate Idempotency-Key. Both come back synchronously with the prior execution id for the ingress to relay.

The verdict callback reaches a thread because the dispatch carried one. A cron tick carries no origin, so a scheduled run — org-spec-audit produces a digest of open questions that belongs in a channel — could not reach that path at all. This is the un-originated half.

A run publishes through the notice capability (packages/core/src/services/notice.ts):

notice.publish({ useCase, text, dedupeKey, links });
A scheduled run publishes a notice Sequence diagram. 5 participants, 6 messages. Participants: Cron tick (C), Run (R), Dispatcher (D), Notice receiver (N), Slack channel (S). 1. Cron tick → Run: start, no origin 2. Run → Dispatcher: notice.publish with useCase, text, dedupeKey, links alt slack-notice.url unset: Note over Dispatcher: logged no-op else configured: 3. Dispatcher → Notice receiver: POST /flare-dispatch/notify, signed with k_notice, deliveryId run:dedupeKey alt useCase unmapped: 4. Notice receiver --> Dispatcher: 403 else mapped: 5. Notice receiver → Slack channel: post escaped text and links 6. Notice receiver --> Dispatcher: 2xx, or 409 when already delivered Note over Run: the run's verdict never depends on the notice altslack-notice.url unsetconfigured altuseCase unmappedmapped Cron tickCron tick RunRun DispatcherDispatcher Notice receiverNotice receiver Slack channelSlack channel logged no-op the run's verdict neverdepends on the notice start, no origin notice.publish with useCase,text, dedupeKey, links POST /flare-dispatch/notify,signed with k_notice,deliveryId run:dedupeKey 403 post escaped text and links 2xx, or 409 when alreadydelivered
A scheduled run publishes a notice
Diagram source
sequenceDiagram
accTitle: A scheduled run publishes a notice
participant C as Cron tick
participant R as Run
participant D as Dispatcher
participant N as Notice receiver
participant S as Slack channel
C->>R: start, no origin
R->>D: notice.publish with useCase, text, dedupeKey, links
alt slack-notice.url unset
Note over D: logged no-op
else configured
D->>N: POST /flare-dispatch/notify, signed with k_notice, deliveryId run:dedupeKey
alt useCase unmapped
N-->>D: 403
else mapped
N->>S: post escaped text and links
N-->>D: 2xx, or 409 when already delivered
end
end
Note over R: the run's verdict never depends on the notice

The shape is the security property. There is no channel, thread, recipient or URL, and no way to express one. useCase is a routing key the receiver resolves against a map in its own deploy config; an unmapped one is refused there. The emit side is the untrusted half — text may be model-authored — so it says what it wants published and never who hears it. A signed body that could name C… would turn one leaked key into workspace-wide write.

text is data. The receiver escapes &, <, > before posting, which neutralizes <!channel>, <@U…> and the rest in one rule. Nothing here builds Slack markup: links that must survive ride in the typed links[] field and are rendered by the receiver from a validated https URL.

A link label is the one field that escaping does not cover, because it lands inside the <url|label> span rather than in the text the receiver escapes. > closes that span early and < opens a new one, so a model-authored label is a way back into markup — and into <!channel> — for anything that only escaped text. The emit side therefore refuses <, >, | and control characters in a label outright (validateSlackNotice), which is stricter than the receiver’s own length-only bound on purpose: it is the half this repo controls. The receiver should escape the label as well; neither guard is sufficient alone.

Wire format, signature and refusal codes are the receiver’s contract (fractalbot specs/flare-dispatch-notify.md, POST /flare-dispatch/notify). The envelope matches the verdict callback — same header, same raw-bytes canonicalization, same SLACK_NOTIFY_SECRET (or HMAC_SECRET) keying material. apps/dispatcher/src/slack-notify.notice.test.ts carries the receiver’s own verification verbatim so the two repos cannot drift silently.

The label differs, and that is a boundary rather than bookkeeping:

k_notice = HKDF-SHA256(ikm, salt = "", info = "flare-dispatch/slack-notice/v1")

One label would make the two surfaces one key, and the two payloads are not equally dangerous. SlackVerdictPayload.origin carries channel and thread_ts — it names a destination, because it relays a conversation the caller was already in. The notice body deliberately cannot. Under a shared key, anything able to sign a notice could sign a verdict naming any channel the bot can see, and “the shape is the security property” would be worth nothing: a shape bounds only while nothing else can sign a different shape with the same key. The weaker credential is now structurally unable to reach the stronger surface.

The ikm is still one secret. Two labels off one secret is domain separation; two secrets would be a second thing to rotate for a separation HKDF already gives.

One secret, two callback keys Flowchart, top to bottom. 5 nodes, 4 edges. ikm SLACK_NOTIFY_SECRET, else HMAC_SECRET → k_verdict [HKDF, info slack-notify/v1]; → k_notice [HKDF, info slack-notice/v1] k_verdict → verdict callback origin names channel and thread k_notice → notice useCase only, no destination HKDF, info slack-notify/v1 HKDF, info slack-notice/v1 ikmSLACK_NOTIFY_SECRET, elseHMAC_SECRET k_verdict k_notice verdict callbackorigin names channel and thread noticeuseCase only, no destination
One secret, two callback keys
Diagram source
flowchart TB
accTitle: One secret, two callback keys
ikm["**ikm**<br/>`SLACK_NOTIFY_SECRET`, else `HMAC_SECRET`"] -->|"HKDF, info slack-notify/v1"| kv["**k_verdict**"]
ikm -->|"HKDF, info slack-notice/v1"| kn["**k_notice**"]
kv --> verdict["**verdict callback**<br/>`origin` names channel and thread"]
kn --> notice["**notice**<br/>`useCase` only, no destination"]
class verdict warn
class notice ok

Deploy the receiver first. The receiver must derive notices under the exact string flare-dispatch/slack-notice/v1. A receiver still on flare-dispatch/slack-notify/v1 rejects every notice with a 401 — silently and indefinitely, because a failed notice is correctly never fatal, so nothing turns red and nothing pages. Verdicts are unaffected either way: their label is unchanged, so an already-deployed receiver keeps verifying them. Ordering: receiver accepts the new label → deploy this dispatcher → the dogfood below.

The receiver dedups on a deliveryId, so the guarantee it can offer is one post per id. That is worth nothing if the id moves between attempts — and a Workflow step can be retried. So the id is derived, never drawn:

deliveryId = "<run>:<dedupeKey>"

No clock, no randomness. A scheduled run passes its day string (the same value its schedule idempotency key uses), so a retry re-sends identical bytes. Date.now() and Math.random() are also simply unavailable on a replayed step, which makes derivation the only construction correct on every path.

409 means delivered, not claimed — a receiver obligation

Section titled “409 means delivered, not claimed — a receiver obligation”

A single-phase claim would be unsafe here. If the receiver marked the id taken and then died before Slack accepted the post, the retry would meet that mark, earn a 409, and this side would record the notice as handled while nothing was ever published — a silence with a success next to it, which is the worst failure this capability has.

So the id carries two states in the receiver’s store, and only the second is a duplicate:

State Meaning Answer to a second POST of the same id
claimed The post was attempted; outcome unknown or failed Not 409 — re-attempt the post, taking over the claim, or answer a 5xx
delivered Slack accepted the post 409
A deliveryId in the receiver's store State diagram, top to bottom. 3 states, 4 transitions. start → claimed [first POST of the id] claimed → claimed [a repeat POST re-attempts the post, or answers 5xx]; → delivered [Slack accepts the post] delivered → delivered [a repeat POST answers 409] first POST of the id a repeat POST re-attemptsthe post, or answers 5xx Slack accepts the post a repeat POST answers 409 claimed delivered
A deliveryId in the receiver's store
Diagram source
stateDiagram-v2
accTitle: A deliveryId in the receiver's store
[*] --> claimed : first POST of the id
claimed --> claimed : a repeat POST re-attempts the post, or answers 5xx
claimed --> delivered : Slack accepts the post
delivered --> delivered : a repeat POST answers 409

409 is reserved for delivered. A receiver that answers 409 for a claimed-but-unposted id breaks the reading on this side, and does it silently. Implementing the split is the receiver’s half (fractalbot specs/flare-dispatch-notify.md); it is written here because it is the assumption the dispatcher’s 409 handling rests on, and because the mirror test is the only place the two repos meet.

The residual window is much smaller but not zero: a post Slack accepted whose delivered write did not land is re-attempted and can double-post. That is the trade this design takes on purpose — for an announcement, a visible duplicate is cheaper than an invisible silence.

This side never upgrades a 409 into a delivery it witnessed. duplicate maps to { delivered: false, duplicate: true }, so the one flag that claims a post reached Slack is set only by a 2xx this dispatcher actually received.

slack-notice.url, not slack-origin.notify-url. Three reasons, any one sufficient: it is a different endpoint (a different route taking a different payload); slack-origin.* is the namespace of the origin-gated policy and a cron notice must not inherit that policy’s trust or be enabled as a side effect of it; and the two have different blast radii, so pointing the announcement path elsewhere must not silence in-thread verdicts a human is waiting on.

The radii differ in the key, not only in the URL. Each surface derives under its own HKDF label (§ The notice signs under its own key), so what a notice endpoint holds is a credential that can publish a use case and cannot name a channel. Pointing slack-notice.url at a staging receiver hands that receiver exactly that and no more — it cannot forge a verdict into a room. Before the split the separation stopped at the URL: one derived key signed both, so an operator moving the announcement path was moving an endpoint while the authority behind it stayed whole.

Delivery is best-effort, like the verdict callback and the completion-notify email: no ingress configured, no signing key, a 5xx, a timeout — all are a logged line and delivered: false. A run’s verdict is earned by what it did, and an announcement that did not land is not one of those things. org-spec-audit keeps its questions PR either way; the file in git is the record, the notice is the announcement, and empty means silent — no questions, no PR, no notice.

The batch path is off until a target repo is pinned. Nothing here is set by default.

Setting Where Purpose
slack-origin.repo CONFIG_KV The one repo slack-origin dispatches may target
SLACK_ORIGIN_REPO wrangler var Same, lower precedence — CONFIG_KV wins
slack-origin.runs CONFIG_KV Narrow the run allowlist (comma-separated); cannot widen
slack-origin.notify-url CONFIG_KV Where the verdict callback POSTs
SLACK_NOTIFY_URL wrangler var Same, lower precedence
slack-notice.url CONFIG_KV Where a notice POSTs — the receiver’s /flare-dispatch/notify
SLACK_NOTICE_URL wrangler var Same, lower precedence
SLACK_ORIGIN_HMAC_SECRET Worker secret Optional Slack-scoped dispatch key
SLACK_NOTIFY_SECRET Worker secret Optional dedicated key material for both callbacks — one secret, two keys (one HKDF label each)
Terminal window
wrangler kv key put --binding=CONFIG_KV "slack-origin.repo" "owner/repo"
wrangler kv key put --binding=CONFIG_KV "slack-origin.notify-url" "https://ingress.example/verdict"
wrangler kv key put --binding=CONFIG_KV "slack-notice.url" "https://ingress.example/flare-dispatch/notify"

Unset slack-notice.url ⇒ every notice.publish is a logged no-op. The receiver additionally needs the use case mapped in its own FLARE_NOTIFY_CHANNELS — an unmapped one is a 403 there, never a message in a room nobody chose.

Dogfood: a Slack mention’s verdict lands in-thread

Section titled “Dogfood: a Slack mention’s verdict lands in-thread”

Needs a live Slack workspace and a deployed ingress, so it is an operator step. Run it end to end once after the first deploy that carries this path.

  1. Pin the target. Set slack-origin.repo and slack-origin.notify-url in CONFIG_KV (commands above). Confirm with wrangler kv key get --binding=CONFIG_KV "slack-origin.repo".
  2. Sync the callback key. The ingress must derive the same k — give it HMAC_SECRET’s value, or set SLACK_NOTIFY_SECRET on both sides. A 401 from the ingress means drift.
  3. Refusals first, before anything real runs. Against the deployed dispatcher, signed with HMAC_SECRET, expect a 403 and the code named:
    • a source-carrying dispatch of offload-test → run_not_allowed_from_slack;
    • one naming a repo other than the pin → repo_not_pinned;
    • one carrying inputs.secrets → credential_selection_not_permitted;
    • release-notes → approval_required, with the conversational-path pointer in message.
  4. The round trip. From the Slack workspace, use the batch-shaped command the ingress maps to an allowlisted run (spec-drift-pr is the cheapest — it takes only firedAt). Expect: the ingress acks in-thread, GET /v1/executions/<id> shows the execution, the flare-dispatch/<run> check-run completes on the pinned repo, and the verdict arrives as a new message in the same thread carrying the run name, the status, and the full-logs link.
  5. The failure branch. Re-run against a commit where the run fails. The in-thread message must read ✗ and carry the failure summary — a verdict path that only works when green is not a verdict path.
  6. Ingress down. Point slack-origin.notify-url at an unroutable host and dispatch again. The run must still complete, the check-run must still post, and the Worker log must carry slack-notify: verdict not delivered. A callback failure never changes a verdict.

Dogfood: a scheduled digest reaches a channel with no thread to reply to

Section titled “Dogfood: a scheduled digest reaches a channel with no thread to reply to”

Needs a deployed dispatcher and a deployed receiver, so it is an operator step. Run it once after the first deploy that carries the notice path.

  1. Point it. wrangler kv key put --binding=CONFIG_KV "slack-notice.url" … (command above), and confirm the receiver maps org-spec-audit in its own FLARE_NOTIFY_CHANNELS.
  2. Sync the key material, and check the label. The receiver’s FLARE_NOTIFY_SECRET must equal SLACK_NOTIFY_SECRET here (or both sides share HMAC_SECRET) — and the receiver must derive the notice key under flare-dispatch/slack-notice/v1, not the verdict label. A 401 means drift, and with a matching secret the label is what to check first.
  3. The round trip. Let the 05:45 UTC tick run against an estate with at least one open question. Expect the questions PR on the control repo and the same text in the mapped channel, carrying the receiver’s via flare-dispatch footer and the PR link.
  4. The replay. Re-run the same day. The receiver must answer 409 and post nothing a second time — the delivery id is org-spec-audit:<day> on both attempts.
  5. Silence. Re-run against an estate with no open questions. No PR, no message.
  6. Ingress down. Point slack-notice.url at an unroutable host and run again. The PR must still open, the run must still be success, and the Worker log must carry notice.publish: org-spec-audit not delivered.