Next.js Server Action Fail-Open: error Read as Success
This article may contain affiliate links. Its content is not affected by advertising.
In short
When a server action judges success only by whether {data} exists, discarding error, a transient DB failure leaves the customer page and Slack contradicting each other.
The short version
A Next.js server action discarded the error from a Supabase DB update and judged success purely by whether {data} existed. As a result, a transient DB failure during a signature hold left the customer page still saying “your signature is on hold” while internal Slack was told, in the opposite direction, “not held – already signed.” It was caught in pre-deploy review on 2026-09-16 and fixed the same day; there’s no record of this path ever running in production.
The symptom
Before the fix, the code handling a signature hold read the update like this:
const { data: heldRow } = await admin
.from("contracts")
.update({ review_status: "レビュー中" })
.eq("id", link.contract_id)
.or("application_status.is.null,application_status.neq.署名済")
.select("id")
.maybeSingle();
const held = !!heldRow;
error isn’t even destructured – it’s discarded outright. held depends only on whether heldRow is truthy, so “no row matched (already signed)” and “the update itself failed (a DB error)” both collapse to the same false.
held is then read in two places. On the customer-facing page:
{!isSigned && (
<>
<br />
Your signature is on hold until we reply.
</>
)}
This only checks isSigned; it never looks at whether the hold UPDATE actually succeeded. In the internal Slack notification:
(held
? "⏸ *Signature held.* The customer cannot sign until staff approves (agreed by memo → approval)\n"
: "※ This contract is already signed, so no hold was placed (post-signing follow-up)\n")
If held is false for any reason, this unconditionally states “already signed, so no hold was placed” – including when heldRow came back empty because of a DB error.
Why
This page accepts pre-signing requests from customers who want contract edits or special terms; a successful hold is what lets staff review before signing. Swallowing error opened exactly the failure path this feature was meant to prevent.
- The customer page, looking only at
isSigned, kept saying “on hold” with the sign button still clickable - Internal Slack assumed the reason
heldwas false was “already signed,” and staff read that as nothing to do
A single DB failure got translated into two separate false reassurances: “safely held” to the customer, “no action needed” to staff. supabase-js doesn’t throw on a transport or query failure – it returns {data: null, error} – so unless error is read explicitly, this kind of failure is swallowed silently.
Fixing it
error is now read, and the reason held came back false is split into “already signed (normal)” and “failed (needs attention)”:
const { data: heldRow, error: holdError } = await admin
.from("contracts")
.update({ review_status: "レビュー中" })
.eq("id", link.contract_id)
.or("application_status.is.null,application_status.neq.署名済")
.select("id")
.maybeSingle();
if (holdError) console.error("[p/token] signing hold failed:", holdError);
const held = !!heldRow && !holdError;
const holdMiss: "signed" | "error" | null = held ? null : holdError ? "error" : "signed";
The Slack message now branches on holdMiss across three cases:
held
? "⏸ *Signature held.* The customer cannot sign until staff approves (agreed by memo → approval)\n"
: holdMiss === "error"
? "🔴 *Failed to place hold.* The customer can still sign right now. Set review_status to \"レビュー中\" immediately\n"
: "※ This contract is already signed, so no hold was placed (post-signing follow-up)\n"
The customer-facing notice was also changed to depend only on whether a hold is actually active (isReviewing), not on isSigned:
{isReviewing && (
<>
<br />
Your signature is on hold until we reply.
</>
)}
hold_miss_reason is now written to the audit log as well, so the reason signing_held came back false can be traced from the log alone.
Lesson
The evidence behind this article does not show any mechanical check added to catch a swallowed error in general – the fix addressed this one call site directly, and whether the same pattern exists elsewhere isn’t something the evidence covers.
What’s structurally visible is that any API returning {data, error} becomes fail-open the moment error is left out of the destructure. This code looked only at whether a result existed, not why it didn’t – and then reused that single verdict for two destinations with very different audiences (a customer-facing message and an internal one), so one swallowed error turned into two separate, contradictory false assurances.
Frequently asked questions
Q1Did this bug actually happen in production?
No. It was caught in pre-deploy review on 2026-09-16 and fixed the same day. There is no record of the broken code ever running in production.
Q2What exactly does "fail-open" mean here?
A design that ignores an error and lets processing continue as if it succeeded. The update read only whether heldRow existed, not the error, so a transient DB failure was silently read as the unrelated case "already signed, so no hold was needed."
Q3What exactly contradicted between the customer page and internal Slack?
Before the fix, if the hold UPDATE failed, the customer page kept showing "your signature is on hold" with the sign button still live, while internal Slack was told flatly "not held, already signed" -- so staff read it as nothing to do and took no action.
Q4What did the fix actually change?
It now reads the update error and splits the miss into held (success), holdMiss="error" (needs attention), and holdMiss="signed" (normal). On failure Slack now says "Failed to place hold," and hold_miss_reason is written to the audit log. The customer notice now depends only on isReviewing.
Environment verified
- next 16.2.7 (kimiteras-portal)
- Update via the Supabase admin client (supabase-js does not throw on failure -- it returns {data, error})
- Found in pre-deploy review on 2026-09-16, fixed the same day (never ran in production)
What this article is based on
- TypeScript file lines 298-305commit 0142fcc
- TypeScript file lines 478-489commit 0142fcc
- TypeScript file lines 299-352commit 3e95468
- TypeScript file lines 480-491commit 3e95468
- JSON file lines 20-20commit 62a03f7
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.