The marker fail-open family

Every sibling of the dedupe marker that posted 96+ ops cards into a paying customer's Slack — which of them share the defect, which can reach a human, and which are being fixed.

2026-10-25 · dock tour-alert-flood row #4 (marker-fail-open-family) · census complete, fixes in flight

The tour marker was ordinary in shape and special in position. Eight more fail-open sites share its exact defect, and three of them can text or email a customer's own residents — but none sits where the flood happened, so each fails open once per event rather than once per call.

The whole census is below, folded by section — open what you need. Written for anyone picking up one of the slice PRs that links here.

The one thing
What happened, and what this page is

On 2026-10-24 a read-only Clara Bench replay posted at least 96 identical ops cards into a live customer's Slack in 46 seconds. The cause was not a sick database: claimTourCalendarAlert's conditional write was refused before it reached DynamoDB by a request-scoped read-only guard, which threw a plain Error. The marker's classifier tested err.name === 'ConditionalCheckFailedException' exactly, so a refusal fell through to a bare return true fail-open — and because every write inside such a scope is refused, "fail open" stopped meaning "alert once instead of going dark" and started meaning "alert once per call".

That marker was fixed in #22322, which also introduced the typed DdbWriteRefusedError and the isDdbWriteRefused helper. This page is the census of its siblings: it exists because the marker that fired was not the only one with that shape, and it is the evidence behind the slice PRs listed at the bottom.

The instrument

The lead that opened this row was a grep counting bare return true lines against file-wide log calls. That cannot distinguish a fail-open from any other return true, nor tell whether a log call sits on the path that matters, so it was replaced rather than acted on.

The real instrument masks comments and string bodies at preserved offsets, brace-matches every catch (…) { }, pairs each one with its own try body, and records per site: whether the try issues a conditional write · whether the catch classifies the conditional failure · whether it rethrows · log calls inside the catch (not file-wide) · every return expression. Two masks deliberately: structure is matched with string bodies blanked, semantics with comments only — so a comment mentioning the exception does not count as a classifier, while a string literal inside a comparison does.

Swept src/ agents/ lambda/ — 5,774 files, 6,031 catch blocks, 285 catches paired with a conditional write, 27 matching the shape. Tests, *.test.ts and gitignored lambda/*/dist/ build output excluded.

Proven to discriminate, both ways, before it was trusted

InputRequiredResult
ntv-escalation-marker.ts (known true positive)2 catches, conditional write, classifies, no rethrow, zero logs in catchexactly that at L95 and L164
Synthetic file: a catch inside a comment; both identifiers in prose and in literals; rethrowscomment catch NOT found; no conditional write; no classifier; rethrow seenexactly that
clara-first-email-intent.ts — the grep's top hit (7)does it have the shape?10 conditional writes, ALL rethrow → not the shape

Three defects the controls caught in the instrument itself

  • A vacuous zero. os.walk on a file argument scanned an empty set, so the positive control first read 0 catches on a file known to have 2. It now exits non-zero rather than reporting a zero when the file set is empty.
  • Masking hid the evidence. Blanking string contents hid classifier literals, so a catch comparing to 'ConditionalCheckFailedException' read as not classifying. Hence the two-mask split.
  • A guard tied to one spelling. The conditional-write test keyed on the exact-case SDK literal ConditionExpression; this repo also conditions through wrapper helpers taking a lowercase conditionExpression, plus attribute_not_exists and transact ConditionCheck. Widening took conditional-write catches from 90 to 285 and candidates from 17 to 27 — and added no new fail-open site.
The defect is one copy-pasted line

Every classifier in this family is a byte-identical copy of:

(err as { name?: string }).name === 'ConditionalCheckFailedException'

at calendar-provider-page-marker.ts:116, call-team-notice-claim.ts:93, hunt-nopickup-marker.ts:172, rating-ask-tenant-cap.ts:110, ntv-escalation-marker.ts:170, and inline at reply-not-sent-alert-marker.ts:65. Every one reads a guard refusal — or any wrapped error — as a store outage.

In scope: the eight sites that can reach a human

Each sink below was traced to the line that performs the send and then verified by hand, not inferred from a filename or a grep count.

FileCatchHuman reachedVerified sinkLogged?
agents/clara/lib/email/outbound-dedup.tsL112external prospect / resident — emailproperty-graph-sender.ts:861 / :1099yes
src/lib/temporal/renewal-receipts.tsL262external resident — SMSactivities/renewal.ts:1064 dispatch(phone, …)yes
dynamo/rating-ask-tenant-cap.tsL76external resident — SMSmaintenance/rating.ts:916yes
dynamo/call-team-notice-claim.tsL212customer's PM staff — email + SMStell-staff.ts:86, open-matter.ts:741no
dynamo/hunt-nopickup-marker.tsL124customer's PM staff — emailtell-staff.ts:86no
dynamo/ntv-escalation-marker.tsL95 + L164internal ops — Sentry fatal pageplatform/logging.ts:293no
dynamo/reply-not-sent-alert-marker.tsL65internal ops — Slack #alertsnotify.ts:557 chat.postMessageno
dynamo/calendar-provider-page-marker.tsL109internal ops — Sentry fatal pageplatform/logging.ts:293no

Five of the eight fail open with no log line at all, so if it happens an operator cannot tell that it did. That, and not an imminent flood, is the strongest part of the case for fixing them.

Why this is NOT urgent — and that conclusion is load-bearing

So these fail open once per event, not once per call. None is a 96-card flood waiting to happen, and the row should not be sold to anyone as one. What remains true is the classifier: any wrapped or re-thrown error still misreads as an outage, and installDynamoMutationGuard() is a second, process-wide refusing door that wraps the onboarding-import dry run and roughly a dozen scripts.

Out of scope, with the reason

Six files whose fail-open cannot reach a human — the cost is a duplicate internal row, a stale admin-grid pointer, or a UI value. Not worth new state.

  • dashboard-stats-snapshot.ts — 'error' folds into the same skip branch as 'held'; renders in UI.
  • recording-peaks-cache.ts — returns false on both branches; the value gates nothing.
  • deck/access-log.ts — slide telemetry; null and true both proceed.
  • automation/automation-run.ts — a stale "latest run" pointer in the admin Jobs grid.
  • appfolio/durable-account-budget.ts — void; internal request pacing and a counter.
  • platform/auth/mfa-attempt-throttle.ts — see the adjacent findings below.

Negative controls

A census where everything examined is a hit is not trustworthy, so two were required:

High count, NOT the shape

clara-first-email-intent.ts — the grep's #1 at 7 hits. It genuinely conditional-writes (10 sites) with the same exact-.name classifier, and is still not in the family for the right reason: every catch does if (!isConditionalCheckFailed(err)) throw err. It rethrows. This is the shape done correctly.

Has the shape, no human

automation/automation-run.ts:194 — a conditional "latest run pointer" write with the identical classifier. Its own comment states the consequence: the automation grid shows an older run. The run row is already persisted; nothing leaves the system.

Also absent from the real census despite grep counts of 2–4: pms-records.ts, organization.ts, lead-received-outbox.ts, pms-mirror-store.ts, pms-write-intent-outbox.ts, website-leads.ts. And the transact-write twin is empty: a conditional transact throws TransactionCanceledException, and the shared helper at helpers.ts:3382 rethrows anything else, so no transact site falls through to a grant.

A shape is not a defect — read the file's own posture first
SiteIts own header saysVerdict
mfa-attempt-throttle.ts:248"Fail-open on storage errors, by choice: a DynamoDB outage must not lock every customer out of their own account. The throttle is a brake on guessing, not an authentication decision" (L43-46)NOT a defect — killed
outbound-dedup.ts:112"Deliberate availability posture — better to risk a duplicate than silently drop a prospect's reply during a DDB outage"defect in the CLASSIFIER only; the fix must not reverse the posture
doorRoutedocumented — but the comment overstated what the code doesfiled as #22340

The MFA case is worth stating plainly because this page previously got it wrong: an outage there removes a rate brake temporarily and never locks an account, never touches enrolment, and never blocks a correct code before the fifth failure. The code check itself is Better Auth's and is unaffected. So it is not an authentication weakening, no issue was filed, and nothing is escalated. It was Q4 = no all along; only the word "weakening" overstated it.

And the sharper point for this family: every one of the eight in-scope sites documents its outage fail-open as a deliberate choice. So the in-scope list is not eight files with a bad posture — it is eight files with a correct, documented posture and a broken classifier. That is precisely why the rule below is "fix the classifier, never the posture", and why a fix that made any of them fail closed on a genuine outage would be reversing a decision somebody made on purpose and wrote down.

What the fix is, and what it deliberately is not

Each fix adds one branch before the outage arm, reusing isDdbWriteRefused rather than re-deriving it, and leaves the outage arm's return value untouched while giving it a log line. A refusal needs no bookkeeping to fail closed — nothing was posted, so there is nothing to bound — and no module-level mutable state is introduced: in #22322 a module-level bound outlived the scope it described, carried between test cases, and silently cost a planted-defect control its ability to discriminate at all.

What this family's defect actually is

The defect is not “a fail-open”. It is an unclassified case falling through to the loud outcome — and it recurs in whatever is built to fix it.

The census found eight correct postures with one broken classifier: every site's fail-open on a store outage was deliberate and documented; the bug was that an unclassified error reached “alert”.

Then the pull request that closed the family produced three recurrences of the same shape, in our own work:

  • an unclassified verdict falling through to “send” — a stale untyped boolean mock matched no branch and dispatched. Fixed with a const _: never exhaustiveness arm, which then immediately surfaced a fifth stale mock that a list of line numbers had missed.
  • a coverage claim wider than its control — the planted-defect control tested what the consumer did with verdicts it was handed, never that the producer emitted them, so deleting the mapping left every test green.
  • a control whose own failure mode emitted its success signal — a planted-defect control succeeds by going RED, so a run refused before it started returned exit 1, indistinguishable from the control working.

Each was caught — twice by review, once by reading a log that could have been skipped. For any assertion whose expected outcome is failure, the exit code alone is never evidence: name the failing tests, confirm a test-summary line exists at all so you know the suite started, and run the unmodified baseline in the same session — exit 0 on the branch is half the evidence.

Slice order and status

One marker end-to-end per PR, each branched off origin/main, never off a sibling.

#SliceWhy hereStatus
1ntv-escalation-marker.tsPattern-setter: the verified control, already tested, plain false suppress value#22344 — merged · live · exercised by test, not yet by production
2reply-not-sent-alert-marker.ts + extract the shared classifierSecond copy would otherwise be written anyway; migrates all existing copies in the same PR#22353 — merged · live
3calendar-provider-page-marker.tsThe one real variation: suppress must stay the full { owns:false, firstSeenAt } object#22356 — merged · live
4call-team-notice-claim.ts + hunt-nopickup-marker.tsOne concern: two guards in series on the same route, same sink#22358 — merged · live
5rating-ask-tenant-cap.tsExternal resident SMS; needs a new test file#22362 — merged · live
6outbound-dedup.tsMost delicate: the file already argues for preferring a missed reply to a duplicate, and the fix must mirror that rather than import a new posture#22359 — merged · live
7renewal-receipts.tsSeparate: returning false hits claimRenewalSend's hard block, which fires a renewal_send_double_block Sentry page — a false statement about a refusal. The branch belongs at that layer.#22363 — merged · live
Adjacent findings — routed, not fixed here
  • One adjacent finding, and it is the only one. claimReplyNotSentAlert's two callers are asymmetric: lead-outcome.ts:120 checks isTestScope and returns before taking the claim; reply-not-sent.ts:197 has no equivalent, so harness traffic both burns the durable claim and posts to #alerts. Filed as #22339, deliberately not folded into the slice that fixes that marker's classifier. The issue itself notes the asymmetry may be deliberate-but-undocumented, so it wants a human read rather than a reflex copy of the sibling guard.
How to re-run the census

The instrument is a standalone script; point it at the trees and read the shape columns. Re-prove it both ways before trusting any new number — it must flag ntv-escalation-marker.ts's two sites and must not flag a catch that appears inside a comment. A count whose instrument was not challenged in both directions is a statement about the instrument, not about the codebase.

PropFlow Docs