Silent Failure Audit — 2026-09-14
Automated read-only audit. No source code was modified.
Summary
| Metric | Value |
|---|---|
| Changed files identified (last 7 days, non-test source) | 987 |
| Files scanned in depth | 95 (85 highest-churn changed + 10 sampled unchanged from workers/api/src/routes and workers/api/src/utils) |
| Catch blocks statically analysed | 1,100+ across TS/TSX/JS; all except handlers in changed Python |
| Critical | 0 |
| High | 3 |
| Medium | 5 |
| Low | 3 |
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:
- The response is never bound or checked. A
400,401,429, or500from/api/feedbackresolves normally, falls straight through, and the prospect sees the success screen. Only a thrownfetch(network/CORS/DNS) reaches thecatch— and that path also shows success. - Nothing is logged, reported, or counted. No
console, noreportClientError, 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.
M2 — Batch link-title fetch failure degrades every link with no telemetry
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 viaconsole.warn. The fallback itself is correctly instrumented (it namesfrom,to, andcode), 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 atwarnwith 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 topageNotes.apps/web/src/lib/api/files.ts:50-80— both the thrown-fetch and the!response.okpaths callreportClientErrorwith a user-visibleref. This is the reference implementation for H1 and H3.- AI provider cascade —
ai-fix.ts:446-466andagentic-vision-converter.ts:618-660log which provider failed, with the original error and both model IDs;anthropic-client.ts:64warns on ZDR fallback. The cascade is not silent (only its transport isconsole.*, see M3). - All changed Python — every broad
exceptcarries a# noqa: BLE001with 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
git log --since="7 days ago" --name-only→ 987 non-test source files changed.- Ranked by churn (
--numstat); took the top 85 non-test files. - 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. - 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/waitUntilfire-and-forget,console.*in server code, discardedawait fetch(...)results, and uncheckedawait supabase...insert/update/delete(...). - 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 |
| Duration | 3m 14s |
| Tool invocations | 19 (17 Bash, 2 Write) |
| Files scanned in depth | 95 |
| GitHub Issues created | 3 |