Tour-alert flood, 2026-10-24 — root cause

Why 96 identical TOUR CALENDAR UNAVAILABLE cards landed in Western Slope's Slack channel inside 46 seconds, named from production data rather than from reading the code.

2026-10-24 21:56 CT · channel C0C283G9QDR (#western-slope) · evidence artifact for dock tour-alert-flood, row #1 · read-only investigation

Candidate (b), and the reason behind it is a third thing neither candidate names. The deliberate fail-open in claimTourCalendarAlert fired on every call. But the claim store was not down and the table name was not wrong — its conditional write was refused before it reached DynamoDB by a request-scoped read-only guard. A deliberate write refusal and a store outage are indistinguishable to that function, so the suppressor was silently disabled while the alert path stayed fully live. The guard was built in #22201; the regression is #22238, which pointed it at the prospect first-email turn build — the one that reads tour availability.

The verdict in one line

A Clara Bench run replaying prospect first-email turns against live Western Slope records built every turn inside a DynamoDB read-only scope. Each turn read tour availability, hit the one genuinely broken calendar binding (Ryan Fleming's), tried to claim the alert, had its claim write refused, read that refusal as a store outage, and posted. Dozens of separate serverless invocations each posted its own card.

Deploy correlation — the prospect-email path went live four minutes before the first card
What posted — observed

Read with agent_harness.slack.read_channel_history_all('C0C283G9QDR', …) over 20:00–01:00 CT.

MeasureValueHow it was established
Cards96Messages matching TOUR CALENDAR UNAVAILABLE in the window
Distinct card text1One sha256 across all 96 — byte-identical
Carry WHO "Ryan Fleming"96 / 96Marker positive- and negative-controlled before use
Causecalendar_binding_unavailable96/96, from the card's cause label
Variant:host_dropped96/96 othersStillQuoted copy
First / last card21:56:06.725 → 21:56:53.089 CTSlack ts 1792896966.725269 / 1792897013.089059
Span46.36 sMedian inter-card gap 0.141 s, minimum 0.013 s
Poster1 botSingle bot identity, no attachments
The discriminator — the marker table, queried not counted

The fail-open path logs nothing at all, so silent logs are not evidence. The only instrument that separates the candidates is the DynamoDB marker the suppressor would have written. All reads below are against propflow-prod; nothing was written, deleted or repaired.

Every org key, in one batch, with the controls inside the same batch

Western Slope resolves to org_c42d9150-b9ad-46f8-8f5f-b0fe56bacf38, but the verdict does not rest on that: production holds only 7 organizations, so all of them were swept — 7 orgs × 4 causes × 2 variants = 56 keys, plus 2 known-present ORG#…/PROFILE rows as in-batch controls.

58 keys requested
RETURNED ROWS: 2
UNPROCESSED:   {}
TOUR_CAL_ALERT rows found:        0
CONTROL rows found (must be 2):   ['ORG#org_c42d9150-…', 'ORG#org_jpco']

The two rows that came back are exactly the two controls. The instrument works; the marker rows are absent.

Every Western Slope property key

90 properties (read from proporg-index) × 4 key variants, in four batches, each batch again carrying a known-present control row. Every batch: per-property-markers=0, control=1, unprocessed=False.

Why absence here means "never written" and not "expired"

A successful conditional put would have left exactly one row and suppressed cards 2…96. Zero rows beside 96 cards means every put failed without raising ConditionalCheckFailedException — which is the fail-open branch, by definition.

The mechanism — each link read in code, matched to the observed route

A complete census of production requests during the flood minute (below) shows the traffic was almost entirely POST /api/internal/bench/context. That route's prospect-email path — added by #22238, which also wrapped it in #22201's scope — runs its whole turn build inside that scope:

POST /api/internal/bench/context   { prospectEmail: … }
  → prospect-email.ts:135      withDdbReadOnly(() => buildTurn(ref))
  → buildTurn:169              readGuestCardEngineTurnInputs
  → guest-card-home-facts-fetch.ts:129  readGuestCardHomeFacts
  → :177                       fetchFirstEmailTourSlots(propertyId)
  → first-email-tour-slots.ts  findTourSlot
  → check-availability.ts      checkAvailability → alertDroppedPoolHosts (:1819)
  → tour-calendar-ops-alert.ts notifyTourCalendarReadFailed
  → tour-calendar-alert-marker.ts  claimTourCalendarAlert → PutCommand
  → ddb-read-only-scope.ts:49  throw new Error('DynamoDB write refused: … It did not reach DynamoDB.')
  → isConditionalCheckFailed(err) === false      // err.name is 'Error'
  → return true                                  // FAIL OPEN
  → postOpsEvent → Slack card

The single defect, stated precisely.

  • claimTourCalendarAlert treats any non-conditional error as "the store is having trouble, better to alert twice than go silent" and returns true.
  • ddbReadOnlyMiddleware refuses a write with a plain new Error(...), whose .name is 'Error'.
  • isConditionalCheckFailed tests .name === 'ConditionalCheckFailedException' exactly. So a refusal reads as an outage.
  • Inside a read-only scope the suppressor is therefore guaranteed to be disabled — not unlucky — while Slack posting is untouched, because Slack is not DynamoDB.

Two things that had to be true for this to produce a card, and both are

Burst arithmetic

A complete request census was taken for 02:56:00–02:56:56 UTC in seven short slices, each returning well under the API's 50-row cap so that each slice is exhaustive rather than a truncated tail.

PathStatusCount
/api/internal/bench/context20050
/api/internal/bench/context40419
/api/internal/bench/context42218
/api/internal/bench/tool—0
homepage + 2 crons2003
Total production requests in the flood minute90
What I looked at and could not see — "could not look", not "nothing there"

Instrument failures hit along the way, recorded so the bad numbers are not trusted

Prior art
PRStateWhat it did to the key or the marker
#10309merged 2026-09-21The original feature. Created tour-calendar-alert-marker.ts and therefore the fail-open this incident rode. Confirmed: it is the only commit to that file.
#13993merged 2026-09-30Added the WHO cell. Its body's claim — "the dedupe key is unchanged, so it is still ONE card per org and cause" — was true as written and is exactly the premise #22201 silently voided.
#17326merged 2026-10-07Introduced the :host_dropped variant that flooded, after an earlier Western Slope incident, with its own dedupe key on purpose. Not an accepted trade-off: it assumed the claim store would answer. 17 days before this flood; it is the shape of the card, not the cause.
#22238merged 2026-10-25 01:34Z — THE REGRESSIONAdded the bench door's prospect first-email path and wrapped its turn build in #22201's read-only scope (prospect-email.ts:135). That build reads tour availability, so this is the PR that pointed a write-refusing scope at the alert. First carried by the 21:47 CT deploy; flood four minutes later.
#22201merged 2026-10-24 20:27Z — the mechanismBuilt the request-scoped read-only guard and attached it to the shared doc client, refusing writes with a plain new Error(...). It touched neither the prospect-email path nor the tour code; on its own it flooded nothing. It is the loaded mechanism, not the trigger.
#22070OPEN — not mergedSame class of bug (an alert marker whose dedupe scope stopped matching its premise) and a fair template, but it has not shipped. The brief described it as recently fixed; that is wrong.

Also: tour-calendar-ops-alert.test.ts carries 14 cases, all sequential, and not one with the claim store erroring. The existing suite was never going to catch this.

What the fix must do
  • Make a refused write distinguishable from an outage. This is the whole bug. Cheapest correct test: inDdbReadOnlyScope() is already exported from ddb-read-only-scope.ts — inside a read-only scope, suppress rather than fail open, because a context that cannot write cannot dedupe and must not alert. Better still, also have the guard reject with a typed, named error that callers can recognise. Prefer both: one defends this call site, the other defends every future one.
  • Keep the durable cross-process marker. The burst was ~87 independent invocations; only shared durable state can dedupe that.
  • Add the test that was missing: the claim store throwing a non-conditional error, and concurrent callers — not just the sequential happy path.
  • Treat the same fail-open shape as a class — and note the two refusers need different fixes. installDynamoMutationGuard() (eval harnesses, the AppFolio dry-run Lambda, backfill scripts) also rejects with a plain Error and would defeat this dedupe identically. But it monkey-patches client.send directly rather than adding middleware, so inDdbReadOnlyScope() would not see it: the scope test covers one refuser, and only a typed or named error covers the other. Any marker-based suppressor in the codebase has this hole.
What the fix must NOT do
  • Must not change the dedupe key. It was already correct and byte-identical across all 96 cards. Adding propertyId, or removing the ?? propertyId fallback, fixes nothing here and would make a future real outage noisier.
  • Must not simply delete the fail-open. Its original reasoning — hearing about a real calendar outage twice beats not hearing at all — still holds for a genuine store outage. Narrow it to the refusal case, or pair removal with a rate cap; do not trade this flood for silence during a real one.
  • Must not treat this as a Slack or alerting-volume problem. Rate-limiting the channel would hide the next instance of a disabled suppressor rather than fix it.
  • Must not rely on the bench door's tool allowlist. The flood came through /bench/context's turn build, not through a tool call — zero /bench/tool requests fired in the flood minute. Narrowing the tool allowlist would not have prevented it.

Evidence gathered read-only against production: Slack conversations.history, DynamoDB GetItem/BatchGetItem/Query on propflow-prod, and Vercel production runtime logs. No production write, delete or marker repair was performed. Code locations are given at the revision serving the incident (release, deployment created 21:47:05 CT 2026-10-24).

PropFlow Docs