Skip to content

Silent Failure Audit — 2026-09-14

Automated read-only audit. No source code was modified.

Summary

MetricValue
Changed files identified (last 7 days, non-test source)987
Files scanned in depth95 (85 highest-churn changed + 10 sampled unchanged from workers/api/src/routes and workers/api/src/utils)
Catch blocks statically analysed1,100+ across TS/TSX/JS; all except handlers in changed Python
Critical0
High3
Medium5
Low3

Headline

The codebase is in good shape on this axis. The degrade paths in the mature subsystems — chunk-assembler, pii-scrubber, link-description, form-detect-executor, the AI provider cascade in ai-fix.ts / agentic-vision-converter.ts, and the entire tools/formspec + flyer-tagger Python surface — are documented, deliberate, and logged. Several carry explicit comments explaining why the degrade is safe and at what level it logs. That is the standard the findings below fall short of.

Exactly one empty-catch pattern exists in the scanned set, and it turned out to be a typed except ValueError: pass with a documented fallback, not a bare swallow.

The three HIGH findings cluster around response codes that are never checked — await fetch(...) and await supabase...insert(...) whose results are discarded. These are invisible to every catch-block-oriented lint rule, which is why they survived.


HIGH

H1 — Sales inquiry form on gevell.com silently discards failed submissions

File: apps/home/src/app/page.tsx:67-78 Severity: High — direct revenue impact, zero observability

try {
await fetch('https://api-pdf.gevell.com/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectName: 'Gevell Sales Inquiry', ... }),
});
} catch {
// Still show success — the email fallback is available
}
setSubmitted(true);
setSubmitting(false);

Two independent silent failures in six lines:

  1. The response is never bound or checked. A 400, 401, 429, or 500 from /api/feedback resolves normally, falls straight through, and the prospect sees the success screen. Only a thrown fetch (network/CORS/DNS) reaches the catch — and that path also shows success.
  2. Nothing is logged, reported, or counted. No console, no reportClientError, no toast. If the feedback endpoint regresses or starts rejecting this payload shape, every inbound sales lead on the company’s primary marketing page is dropped and the first signal is “the pipeline went quiet.”

The comment’s premise (“the email fallback is available”) is not enforced in code — the user is shown unconditional success, not an email fallback.

Contrast with the correct pattern in the same app: apps/home/src/app/sso/SsoIntakeForm.tsx:80-87 checks res.ok and json?.success === false, surfaces a real error, and distinguishes transport failure from rejection. H1 should follow it.

Recommended fix: bind the response, branch on res.ok/success, show the documented email fallback on failure, and report via a client error reporter.


H2 — Triage feature: 8 unchecked Supabase writes, including the agent’s paid AI reply

File: workers/api/src/routes/admin-triage.ts — lines 134, 145, 188, 195, 203, 308, 358, 366 Severity: High — data loss on a paid AI call; not yet deployed, so this is catchable pre-ship

supabase-js does not throw on a database error — it resolves with { data, error }. Eight writes in this file discard that object entirely. The same file demonstrates the correct pattern at line 300 (const { error: mErr } = await ... ; if (mErr) return error(...)), so this is inconsistency, not an unknown idiom.

The worst instance, line 134:

// Persist the agent's reply.
await supabase.from('triage_messages').insert({
thread_id: threadId,
role: 'agent',
body: decision.replyToEmployee,
token_usage: decision.usage ?? null,
});

An employee’s question has just consumed a full Opus tool-loop. If this insert fails (RLS, constraint, connection), the answer is gone — not retried, not logged, not surfaced — while the cost is already booked. The employee sees a thread that ran and produced nothing.

Line 203 is a second-order version of the same problem: it lives inside the catch at line 202 and writes the “Triage could not complete automatically” recovery message. If that write fails, the thread is left with no error message and status awaiting_larry, and nobody learns why.

Line 308 (update({ status: 'open' }) on reopen) fails silently in the admin UI: the caller returns success(c, { ok: true }) regardless.

Recommended fix: destructure { error } on all eight, and at minimum log.error(...) each. For line 134 specifically, treat a failed persist as a job failure — the cost was incurred and the result must not evaporate.

Per memory/project_employee_triage.md this branch (feature/employee-triage) is committed but not pushed or deployed, so this can be fixed before it ever runs in production.


H3 — LTI platform deletion fails silently after a destructive-action confirm

File: apps/web/src/app/tenant-admin/lti/page.tsx:126-133 Severity: High — a confirmed destructive action reports no outcome

if (!ok) return;
try {
await fetch(`${API_BASE_URL}/api/tenant-admin/lti-platforms/${id}`, {
method: 'DELETE',
headers: headers(),
});
fetchPlatforms();
} catch (err) {
console.warn('Failed to delete LTI platform:', err);
}

The user has just passed a variant: 'destructive' confirmation dialog reading “This can’t be undone.” The DELETE response is never checked. On a 403 (insufficient tenant role) or 500, fetchPlatforms() runs, the row reappears, and no error is shown — the user is left to conclude the click didn’t register and try again.

The catch is transport-only and downgrades to console.warn, which never reaches Loki.

Recommended fix: check res.ok, surface a toast via the shared notify system on failure, and report the error.


MEDIUM

M1 — Bedrock kill-switch admin page tells operators to “check console” but never writes to it

File: apps/admin/src/app/admin/ai-controls/page.tsx:160, 183

} catch {
setActionError('Action failed — check console');
}

grep -c "console\." on this file returns 0. The error object is discarded before anything is written anywhere. The operator is directed to a console that is, by construction, empty.

This page drives the AI kill switch (disableBedrockPdf / resumeBedrockPdf). Failing to disable AI PDF conversion during a cost or compliance incident, with the cause thrown away, is the exact scenario where the error detail matters most. The load() catch at line 160 has the same shape.

Recommended fix: capture the error, render its message, and report it. At minimum make the instruction true.


File: workers/api/src/services/link-description.ts:471-477

try {
titles = await fetchLinkTitles(httpUrls, fetchOptions);
} catch {
titles = new Map();
}

The per-URL failures inside fetchLinkTitles are deliberately and correctly handled (:327 documents timeout/DNS/TLS as expected, returning null so the caller humanizes the URL). This outer catch is different: it covers a failure of the whole batch — a scheduler bug, an OOM, a runtime abort — after which every link in the document quietly falls back to a humanized URL instead of a real title.

Output quality drops across the board and there is no log line to correlate it with. Every sibling degrade in this file carries a void err plus an explanatory comment; this one has neither.

Recommended fix: getAppLogger()?.warn('link_titles_batch_failed', { count: httpUrls.length, message }).


M3 — console.* instead of the structured logger across API routes

Files (≈35 sites in the scanned set): workers/api/src/routes/admin-site-audit.ts (10), acr.ts (11), benchmark.ts (6), admin-crm.ts (2), admin-triage.ts (2), convert.ts (4+), internal.ts (2), services/ai-fix.ts (2), apps/web/src/lib/api/crm.ts (1)

The global standard is explicit: “Use log.error('message', err, metadata) — not console.error” and “No console.log in production code.” These lines are real error handling — nothing is swallowed — but they land in stdout without the structured fields (requestId, userId, service, level) that Promtail extracts. They are therefore invisible to the Grafana alert rules, including “Fatal error: any fatal log → critical” and “High error rate: >10 errors/min”.

Notable instances where this materially matters:

  • acr.ts:194 — console.warn('[acr] supabase unreachable, treating decisions as empty:', err). A Supabase outage degrades ACR decisions to empty and triggers no alert.
  • acr.ts:265 — the pre-grader fails and the queue is returned without AI suggestions, warned only to stdout.
  • ai-fix.ts:457 — the Nova→Haiku provider fallback is logged via console.warn. The fallback itself is correctly instrumented (it names from, to, and code), but a sustained Nova regression would not raise an alert.
  • ai-fix.ts:427 — cost-ledger write failure. Per the global cost-tracking standard, a dropped ledger row is an accounting gap; it deserves a real error log.

Recommended fix: migrate these to @anglinai/logger. Given the volume, do it per-file as those files are next touched rather than as one sweeping PR.


M4 — PDF page-count failure silently becomes “1 page”

Files: workers/api/src/routes/convert.ts:5566-5570, workers/api/src/routes/internal.ts:103-108

let totalPages: number;
try {
totalPages = await countPdfPagesUtil(fileData);
} catch {
totalPages = 1;
}

A corrupt, encrypted, or unusual PDF whose page count can’t be read is silently treated as single-page. Downstream this drives autoSamplePages() (so a 40-page document previews only page 1) and, in internal.ts, the pageCount on the file record — which feeds billing and progress UI. Neither site logs.

Recommended fix: keep the fallback, but log at warn with the fileId so a systematic parser regression is visible, and consider surfacing “page count unavailable” rather than asserting 1.


M5 — Preserve-pipeline report failure is invisible (self-documented)

File: apps/pro/src/lib/use-preserve-pipeline.ts:126-137

The code already states the problem:

“TODO: report to telemetry once this app has a client error reporter — a report-endpoint outage is currently invisible.”

The reasoning for swallowing is sound (a report failure must not sink a delivered, paid job), but apps/pro has no client error reporter, so a total outage of the PDF/UA report endpoint would show up only as users noticing the report link is gone. apps/web and apps/vpat both have reportClientError; apps/pro does not.

Recommended fix: wire reportClientError into apps/pro and close the TODO.


LOW

L1 — Converter handoff failure redirects with no telemetry

apps/pro/src/components/PolishPanel.tsx:100-108 — stageForConverter failing sets handoffState: 'error' and redirects the opened tab to CONVERTER_FALLBACK_URL. The user is not stranded, but the staging failure is never recorded. Same root cause as M5 (no reporter in apps/pro).

L2 — Per-page contrast check skipped without a note in the report

tools/docx2acroform/docx2acroform/propose.py:144 — a page that can’t be rasterized returns None and contrast is skipped for it. Correctly typed and documented, but the fidelity report does not record which pages were skipped, so “contrast checked” is not distinguishable from “contrast silently not checked.”

L3 — Webhook dispatch result unobserved on Node

workers/api/src/routes/convert.ts:6051-6055 — catch { void task; } around c.executionCtx.waitUntil(task). Benign: task already carries its own .catch(...), so no unhandled rejection and the webhook still runs on Node. Noted only because the pattern reads as a swallow at a glance.


What was verified as correct (no action needed)

Worth recording so future audits don’t re-flag these:

  • workers/api/src/utils/pii-scrubber.ts:109, 155 — fail-open by design, and the comments explain the level choice: “Log at error, not warn: a failure here means PII/PHI protection is silently disabled for a user who explicitly configured it.” Exemplary.
  • workers/api/src/services/chunk-assembler.ts — 14 non-blocking degrade paths, every one logged at warn with a component tag and errorId.
  • workers/batch/src/form-detect-executor.ts — 13 catch blocks, all logged with jobId; failures that change user-visible state push to pageNotes.
  • apps/web/src/lib/api/files.ts:50-80 — both the thrown-fetch and the !response.ok paths call reportClientError with a user-visible ref. This is the reference implementation for H1 and H3.
  • AI provider cascade — ai-fix.ts:446-466 and agentic-vision-converter.ts:618-660 log which provider failed, with the original error and both model IDs; anthropic-client.ts:64 warns on ZDR fallback. The cascade is not silent (only its transport is console.*, see M3).
  • All changed Python — every broad except carries a # noqa: BLE001 with a stated rationale, and the AI lanes (enhance_ai.py:194, from_detected.py:789) both log and record the reason in the returned report.
  • workers/api/src/routes/internal.ts:320-340 — defence-in-depth on the detached run, with comments explaining the CF-vs-Node split.

Method

  1. git log --since="7 days ago" --name-only → 987 non-test source files changed.
  2. Ranked by churn (--numstat); took the top 85 non-test files.
  3. Sampled 10 unchanged files from workers/api/src/routes/ and .../utils/: acr.ts, admin-site-audit.ts, alt-decisions.ts, benchmark.ts, convert-stream.ts, feedback.ts, forms-polish.ts, internal.ts, music.ts, pdf-validate.ts.
  4. Brace-matching Python analyser over every catch (...) { ... } body in the 95 files, flagging bodies with no log call, no rethrow, and no user-facing surface; plus regex passes for empty catches, except: pass, .then() without .catch(), void/waitUntil fire-and-forget, console.* in server code, discarded await fetch(...) results, and unchecked await supabase...insert/update/delete(...).
  5. Manually read every flagged site in context before classifying.

Note: the two highest-severity findings (H1, H3) and half of H2 were found by the discarded-return-value passes, not the catch-block pass. Future runs of this audit should keep those checks — catch-block analysis alone would have scored this codebase clean.

Resource usage

Start (UTC)2026-09-14T07:17:25Z
End (UTC)2026-09-14T07:20:39Z
Duration3m 14s
Tool invocations19 (17 Bash, 2 Write)
Files scanned in depth95
GitHub Issues created3

Issues filed

  • #3045 — H1 gevell.com sales inquiry form silently discards failed submissions
  • #3046 — H2 triage routes have 8 unchecked Supabase writes
  • #3047 — H3 LTI platform deletion fails silently after a destructive-action confirm