Silent Failure Audit β 2026-09-07
Automated read-only audit. No source files were modified.
Scope
- Changed files (last 7 days): 528 source files (
.ts/.tsx/.py/.mjs, tests and.d.tsexcluded), spanningapps/admin(287 β the new admin clone landed this week),workers/api(58),apps/web(46),apps/forms(42),services/flyer-tagger(25), plus tools/packages. - Random unchanged sample (10 files) from
workers/api/src/routes/andworkers/api/src/utils/:account.ts,admin-pricing.ts,admin.ts,artifact-download.ts,benchmark.ts,client-logs.ts,download.ts,files.ts,jobs.ts,lti.ts.
Detection method: brace-matched catch-body extraction (Python AST-free scanner) flagging bodies with no
log/console/throw/reject/user-visible-error call; plus greps for empty catches, console.error|warn,
floating .then() without .catch(), and detached/void promises.
Summary
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 2 |
| Medium | 6 |
| Low | 4 |
- 230 catch blocks with no logging/rethrow were flagged mechanically; 89 of those are in
apps/admin, which is a clone ofapps/webβ those duplicate theapps/webfindings rather than adding new ones. - The overwhelming majority of flagged catches are legitimate, commented guards (JSON body β
400,new URL()validation,localStorageblocked in private mode, LLM-JSON parse βnullwith a caller-side check). Those are not reported below. - Only 2 empty
catch {}exist in the changed set, both benign:apps/admin/public/pdf.worker.min.mjs(vendored pdf.js bundle) andworkers/api/scripts/build-lambda.mjs:104(build script). - The random unchanged sample was clean: no unlogged catch of a critical operation, no
.then()without.catch().
Findings
HIGH-1 β Page-count failure silently truncates a Textract conversion to page 1
File: workers/api/src/services/textract-converter.ts:137-143
let pageCount = 1;try { pageCount = await countPdfPages(pdfData);} catch { pageCount = 1;}Why it matters: if countPdfPages throws on a 40-page document, the converter renders and bills page 1 only
and returns a βsuccessfulβ conversion. Nothing is logged, no note is attached to the output, and the user has no
signal that 39 pages are missing. The comment says most uncountable inputs are single-page β but the failure mode
when that assumption is wrong is silent content loss in the deliverable.
Fix: log the throw at warn via getAppLogger() (workers/api/src/utils/app-logger) with filename and the
error, and record a note on the conversion output (the pattern already used by
textract-form-proposer.ts:481, which pushes 'page count fell open to 1' into notes). Consider failing the job
outright when the PDF is larger than a size threshold that makes βitβs a one-page imageβ implausible.
HIGH-2 β Status polling swallows every error, leaving the UI spinning forever
File: apps/forms/src/app/dashboard/[jobId]/ConversionDetailClient.tsx:62-71
const interval = setInterval(async () => { try { const updated = await getConversionStatus(jobId, token); ... } catch { // ignore }}, 3000);Why it matters: an expired token, a 5xx, or an API outage produces an unbounded silent retry loop. The user sees
a progress indicator that never resolves and never errors β the exact failure class the projectβs error-reporting work
(useNotifyError / βweβve been notifiedβ) was built to eliminate. It also has no consecutive-failure ceiling.
Fix: count consecutive failures; after N (3β5) stop the interval, surface a visible error state, and report via
useNotifyError/reportClientError as apps/webβs TagEditor.tsx:147 already does.
MEDIUM-1 β Preview page-count fallback silently previews only page 1
File: workers/api/src/routes/convert.ts:5292-5297 β same catch { totalPages = 1 } shape as HIGH-1, feeding
parsePageRanges/autoSamplePages. A parse failure collapses the preview sample to a single page with no log.
Fix: log the error and return an explicit PDF_UNREADABLE error, or mark the preview as partial in the response.
MEDIUM-2 β AI cost estimate falls back to a magic number without logging
File: workers/api/src/routes/forms.ts:268-275
} catch { // Non-fatal β use fallback estimate estimatedAiCostUsd = pageCount * 0.05;}The global standard requires incremental-cost operations to record real cost. A silent $0.05/page guess pollutes
cost reporting with no marker distinguishing estimated-from-fallback rows.
Fix: log at warn, and persist a costEstimateSource: 'fallback' flag alongside the number.
MEDIUM-3 β Malformed AcroForm field tree silently reroutes a fillable PDF to the wrong lane
Files: workers/api/src/utils/pdf-utils.ts:48-55, apps/forms/src/lib/classify-pdf.ts:53-57
try { fieldCount = doc.getForm().getFields().length; } catch { fieldCount = 0; }fieldCount === 0 classifies the document as flattened, sending a real fillable form down the scan/vision lane
instead of the AcroForm lane. The parse failure β the only evidence that the classification is a guess rather than a
reading β is discarded on both server and client.
Fix: carry a fieldTreeUnreadable: true flag out of the classifier so the engine preflight and the UI can say
βwe couldnβt read this formβs field treeβ instead of quietly changing product behaviour.
MEDIUM-4 β Visual-layout scoring failure is discarded, silently degrading tier selection
File: workers/api/src/services/smart-cascade-converter.ts:1919-1922
} catch (_err) { /* visual layout scoring failed β ignore */ }iterVl stays undefined, so the βaccepted at this tierβ check can never pass and the page silently escalates to a
more expensive tier (or lands on a worse result). A systematic scorer outage would show up only as an unexplained
cost/quality shift.
Fix: log at warn with page number and model, and count scorer failures in the jobβs metrics.
MEDIUM-5 β Scheduled sweeps report failures only to console.error
File: workers/api/src/index.ts:478-510 (four sweeps: result-email, CRM reminders, issue reminders, team alerts)
Each sweep wraps its body in try/catch β console.error. These are unattended cron paths whose entire purpose is
notification; a persistently failing sweep produces no structured log line, no Loki label, and no alert. This is the
same class of gap as the dead error-alerting pipeline found on 2026-09-04.
Fix: route through the structured logger at error level so the existing Grafana βfatal/error rateβ rules fire.
MEDIUM-6 β console.error/console.warn instead of the structured logger (systemic)
Confirmed occurrences in changed server-side code (non-exhaustive):
workers/api/src/lambda-stream.ts:147,157,184,214; workers/api/src/index.ts:418,451,483,492,500,509;
workers/api/src/routes/acr.ts:137,187,194,265,285,368,400,432,527,580,586;
workers/api/src/routes/admin-crm.ts:90,113; workers/api/src/mcp/tools/acr.ts:129,136,220,237;
workers/api/src/services/image-enhancer.ts:258,285; workers/api/src/routes/gateway.ts:109,278;
workers/api/src/routes/convert.ts:5775; packages/shared/src/scan-metrics-writer.ts:36.
These lines are lost to log aggregation (no requestId, userId, level, or service label) and cannot trigger
alerting. getAppLogger() from workers/api/src/utils/app-logger is already available and used by
chunk-assembler.ts.
Fix: mechanical replacement; consider an ESLint no-console rule for workers/**/src/** to stop the regression.
LOW-1 β Detached conversion promises on the Node path
workers/api/src/routes/gateway.ts:113 and :282. Both promises attach .catch() before being detached, so no
unhandled rejection β but the bulk prewarm path only console.errors and never marks a job failed, so a prewarm
that always fails is invisible. Add a structured log and a failure counter.
LOW-2 β Cache-invalidation deletes swallowed
workers/api/src/routes/files.ts:1189 and :1399 β catch (_err) { /* ignore if already gone */ } around
storage.objects.delete(acrReportR2Key). βAlready goneβ is fine; a genuine R2 outage is not, and would leave a stale
ACR report served against edited HTML. Log at debug/warn rather than discarding.
LOW-3 β localStorage guards
apps/web/src/components/tag-editor/TagEditor.tsx:119,128 (and the apps/admin clone). Correct behaviour
(private-mode fallback); the second one deserves the same explanatory comment as the first rather than /* ignore */.
LOW-4 β LLM-JSON parse fallbacks return null unlogged
alt-text-suggester.ts:119,190, verification-prograder.ts:119, acr-composer.ts:177,
image-region-recovery.ts:129, chunk-assembler.ts:100. All callers null-check, so behaviour is safe; but a model
that starts emitting unparseable JSON degrades output quality with zero telemetry. A single warn counter per parse
helper would make that visible. Note image-enhancer.ts already does this well (four-stage recovery, parseMethod
recorded, console.warn on total failure) β it is the pattern to copy, modulo MEDIUM-6.
Not findings (verified benign)
catch { return error(c, 'BAD_REQUEST', 'Request body must be JSON.', 400) }β ~25 occurrences acrossflyer.ts,forms-builder.ts,tagging-api.ts. Correct: the user gets a specific response.catch { /* invalid origin URL β fall through */ }inindex.ts,index-aws.ts,server.tsCORS handling.workers/api/src/services/chunk-assembler.tsnon-blocking recovery paths β every one logswarnwith anerrorIdand component. Reference-quality handling.workers/batch/src/*-executor.tsterminal catches all callfail(...), which persists job failure state.apps/forms/src/components/review/FieldReviewCanvas.tsx:130β sets a user-visiblepageError.
Resource usage
- Start: 2026-09-07T07:17:51Z
- End: 2026-09-07T07:31:04Z
- Duration: ~13m
- Tool invocations: 16 (1 git log, 13 grep/scan/read Bash calls, 2 report/issue steps)
- Files scanned: 538 (528 changed + 10 random unchanged)