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.
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.
Read with agent_harness.slack.read_channel_history_all('C0C283G9QDR', …) over 20:00–01:00 CT.
| Measure | Value | How it was established |
|---|---|---|
| Cards | 96 | Messages matching TOUR CALENDAR UNAVAILABLE in the window |
| Distinct card text | 1 | One sha256 across all 96 — byte-identical |
| Carry WHO "Ryan Fleming" | 96 / 96 | Marker positive- and negative-controlled before use |
| Cause | calendar_binding_unavailable | 96/96, from the card's cause label |
| Variant | :host_dropped | 96/96 othersStillQuoted copy |
| First / last card | 21:56:06.725 → 21:56:53.089 CT | Slack ts 1792896966.725269 / 1792897013.089059 |
| Span | 46.36 s | Median inter-card gap 0.141 s, minimum 0.013 s |
| Poster | 1 bot | Single bot identity, no attachments |
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.
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.
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.
ttl is expiresAt/1000 — a row written at 21:56 CT expires 2026-10-25 21:56 CT.TOUR_CAL_ALERT# row.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.
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.getPersonsByIds → batchGetRereadingOnce, which uses the same TABLE_NAME constant and the same doc client as the refused write. Reads pass the guard; writes do not. Same invocation, opposite outcomes — which also rules out a wrong or missing DYNAMODB_TABLE_NAME.postOpsEvent performs only reads before its Slack call at ops-event.ts:683; its one DynamoDB write (the conversation thread anchor) happens after the post and deliberately swallows its own errors. So nothing on the posting path needed a successful write.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.
| Path | Status | Count |
|---|---|---|
/api/internal/bench/context | 200 | 50 |
/api/internal/bench/context | 404 | 19 |
/api/internal/bench/context | 422 | 18 |
/api/internal/bench/tool | — | 0 |
| homepage + 2 crons | 200 | 3 |
| Total production requests in the flood minute | 90 |
findTourSlot walk (MAX_DAYS = 5), not ~10. And host_dropped means other hosts still had slots, so the walk resolves early — in practice about one claim per request.buildTurn. ≥96 cards over ~50 successful builds is ≈2 cards per build — consistent with fetchFirstEmailTourSlots wanting a morning and an afternoon slot and walking a second day. This was not one walk: it was dozens of separate serverless invocations, each making its own claim.propflow-prod, and all 90 requests served one deployment on branch release, created 21:47:05 CT and Ready roughly four minutes before the first card.claimTourCalendarAlert returns true on a store error with no logWarn. CloudWatch and Sentry silence on candidate (b) is expected and carries no information either way.--limit; a wide window silently returns the most recent 50, which is a tail. Only short windows are exhaustive.refusedWrites report was not retrieved — it is returned to the caller (repo clara-bench), not persisted here. Note too that the alert is fire-and-forget, so a refused write can land after refusedWrites is read; a report that omits it would not be exoneration.vercel logs --limit 1000 returned 50 rows. The cap is 50 and the flag is ignored — a count from one unsliced call is meaningless.set -- $W; zsh does not word-split, so every slice received one malformed timestamp and returned 0 rows. That zero was my own bug, and it looked exactly like "no traffic". The slices were redone with explicit variables, and the one-minute window served as the control that proved traffic existed.ItemCount on a table or index was never used as evidence; every marker result above is a key-level read.| PR | State | What it did to the key or the marker |
|---|---|---|
| #10309 | merged 2026-09-21 | The 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. |
| #13993 | merged 2026-09-30 | Added 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. |
| #17326 | merged 2026-10-07 | Introduced 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. |
| #22238 | merged 2026-10-25 01:34Z — THE REGRESSION | Added 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. |
| #22201 | merged 2026-10-24 20:27Z — the mechanism | Built 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. |
| #22070 | OPEN — not merged | Same 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.
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.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.propertyId, or removing the ?? propertyId fallback, fixes nothing here and would make a future real outage noisier./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).