Ops Troubleshooting Runbook
Operational playbook for the on-call accountant/engineer. Each entry: symptom → where to look → what it means → action. Most "stuck" states are by-design back-pressure, not bugs — confirm against this list before escalating.
Audit trail is the source of truth. Every state change writes an
audit_eventsrow (entity_type,entity_id,event_type). When in doubt, query the audit log for the affected entity before touching data.
Payroll
Duplicate-receipt warning on a cycle (NOT blocking)
- Symptom: validation surfaces a duplicate-receipt issue but export is still allowed.
- Meaning: by design. Of the 5 core validation rules, 4 are BLOCKING (attendance, change docs, IR21, NS docs) and duplicate receipts is a WARNING only (
packages/domain/src/payroll/rules/validation-rules.ts). - Action: the PE reviews and proceeds; duplicates never halt an export. Document the decision in the cycle notes. Do not "fix" by editing validation.
Magic links point at the wrong host
- Symptom: invite/reminder/approval emails link to
localhost. - Meaning:
PORTAL_BASE_URL/PORTAL_URL_BASEnot set in the environment. - Action: set the production domain in the server
.envand restart the worker.
Bookkeeping
A bank statement is stuck "parsing" then goes FAILED
- Symptom:
BankStatement.parseStatussits at PENDING, then FAILED after retries. - Where:
apps/worker/src/handlers/bookkeeping/parse-bank-statement.ts. - Meaning: by design — the parser throws to trigger a pg-boss retry until the document's OCR extraction completes. If OCR never completes, retries exhaust and the statement is marked FAILED.
- Action: check the source document's extraction (
documents/ OCR logs). If OCR failed, see "OCR / extraction failed" below, then re-trigger the statement parse.
Reconciliation run has "neither statementId nor periodId"
- Symptom: error logged from
reconciliation.service.ts(defensive throw). - Meaning: a data-integrity violation that should never occur if the schema is intact.
- Action: alert engineering. Capture the
ReconciliationRun.id; do not retry blindly.
Low-confidence journal entries auto-drafted
- Symptom: entries with LOW / UNCLASSIFIED confidence appear as DRAFT, not escalated.
- Meaning: classification confidence is advisory; the reviewer decides.
- Action: reviewers must inspect confidence before approving; ESCALATE manually when unsure.
Client configured for SharePoint ingests nothing
- Symptom: a folder-sync client receives zero documents.
- Meaning: SharePoint's adapter is a placeholder (empty pulls). Channel creation is now gated (
POST .../ingestion-channelsreturns 422 forSHAREPOINT), but any pre-existing SharePoint channel still no-ops. - Action: move the client to Google Drive or Dropbox. Delete the SharePoint channel.
Shared — OCR / extraction failed
- Symptom: a document shows
EXTRACTION_FAILED; downstream parse/classify stalls. - Meaning: the OCR provider (Vision + Claude) errored. (
OCR_PROVIDER=mockcan no longer reach production: the worker refuses to start with it unlessALLOW_MOCK_AGENT_AI=true.) - Action: (1) confirm
OCR_PROVIDERis the real provider in the server.envand the Vision/Anthropic credentials resolve; (2) re-enqueue theocr-processjob for the document; (3) if the provider is down, OCR has no automatic fallback — wait or retry.
Shared — Chatwoot messaging
- Symptom: Settings → Integrations → Chatwoot is red/Unreachable; creating a Telegram inbox 503s; the Inbox composer 503s on Send.
- Meaning: outbound calls to the Chatwoot API are failing. Most often a bot-protection / WAF challenge in front of the Chatwoot host answering our server-to-server requests with a 403 HTML page. Inbound webhooks keep working, so messages still arrive — only sending breaks.
- Action: follow
docs/runbooks/chatwoot-unreachable.md. CheckchatwootFailureKindin the API logs first:BOT_PROTECTION→ that runbook;AUTH→ the token, seesecret-rotation.md. - Workaround meanwhile: staff can reply from the Chatwoot UI. Those replies still reach the console via the
chatwoot.mirrorjob, which the block does not touch. - Do not close until a reply sent from the console composer has been confirmed delivered — inbox creation succeeding does not prove the send path is healthy. Use Internal note mode to test against a live client thread without messaging anyone.
- A 502 (not 503) from the composer is a different fault: Chatwoot answered and refused the message. Wrong runbook.
Shared — a client's file or message did not arrive (since 28 Oct 2026, stage review S2-05)
- Bell: "a file the client sent could not be received" (
CLIENT_FILE_NOT_RECEIVED): an attachment on a client's message did not download from Chatwoot after three tries, or could not be stored. Nothing was filed, but the client may already have had "we've received your documents". The conversation's history has achatwoot.attachment.not_receivedrow naming the file and the error. It goes to the company's assignee (assigned executive, else the group's relationship manager), or the Platform Admins when the thread has no company or no assignee. Action: ask the client to send that file again. - Bell: "Messages from Chatwoot inbox N (account A) are being dropped" (
CHATWOOT_UNKNOWN_INBOX): a Chatwoot inbox no BreezyCorp inbox matches (or more than one does). Every message to it is answeredignoredand audited aschatwoot.unmatched_inbox. Each Platform Admin is told once per inbox. Action: connect the inbox under Settings → Integrations, or take the agent bot off that inbox in Chatwoot. - A typed message read as payroll data that could not become a document now fails the
msg.inbound-textjob, which is retried (3 times, then the failed-jobs list), instead of being swallowed.
Financial Statements
Engagement stuck at NOTES_ERROR
- Symptom: engagement status is
NOTES_ERRORafter notes generation. - Where:
NotesService.generate(packages/domain/src/financial-statements/services/notes.service.ts). - Meaning: by design — a Claude/parse failure transitions to the recoverable
NOTES_ERRORstate and writes afs.notes.failedaudit event. The job completes (it does not retry a non-deterministic LLM call). - Action: read the
fs.notes.failedauditreason. Fix the input if needed, then re-trigger notes generation (POST /ops/financial-statements/engagements/:id/notes/auto-generate). The FSM allowsNOTES_ERROR → STATEMENTS_GENERATED → NOTES_COMPLETE.
Client requested a revision
- Symptom: an engagement at
NOTES_COMPLETEreverts toSTATEMENTS_GENERATED. - Meaning: by design — the client used the portal "request revision" action (
fs.client.revision_requestedaudit). The preparer regenerates notes after addressing the comment in the auditeventDataJson.
TB upload "succeeds" but mapping shows nothing
- Meaning: the trial-balance parse produced zero rows (bad file) — see the
fs.trial_balance.parse_failedauditreason. - Action: re-upload a clean CSV/XLSX.
XBRL Filing
Document ingest rejected as "not a recognizable financial statement"
- Symptom:
XbrlSourceDocument.parseStatus = FAILED; filing stays in DRAFT. - Where:
DocumentIngestionService.runIngestpre-ingestion gate. - Meaning: by design — the document parsed but had no line items or only notes (no primary statement). A
xbrl.document.ingest_rejectedaudit records the reason. The job is swallowed (no DLQ cycling) because a retry won't change the file. - Action: confirm the uploaded DOCX/PDF actually contains a balance sheet / P&L, then re-ingest the corrected document.
Export blocked (HTTP 422)
- Meaning: the export gate (Rules 2 & 3) refuses output until validation has passed and every tag mapping is human-confirmed (
assertExportAllowed). - Action: run validation; confirm any unconfirmed mappings; retry the export. This gate is non-negotiable — do not bypass it.
Worker / queue health
- Dead-lettered jobs — a job that exhausts its retries is routed to the
dead-letterqueue (configured on every ad-hoc queue inapps/worker/src/handlers/index.ts). A single consumer logs it at error level (worker.job.dead_letter), increments the in-processworker.job.dead_lettercounter, captures it to Sentry, and writes an append-onlyaudit_eventsrow (eventType = 'worker.job.dead_letter',entityType = 'PgBossJob').- To review dead-lettered jobs: query
audit_events WHERE event_type = 'worker.job.dead_letter'(since SEC-5, 27 Oct 2026, theevent_data_jsonholds the queue, the source job id, the payload's ids aspayloadand its key names aspayloadKeys, and the failure's message asoutput.message— never the payload's values, which can hold a sign-in code, an address or pay), or grep worker logs forworker.job.dead_letter. To see the full payload, read the failed job itself inpgboss.job(bysourceJobId) as the migrate role. - Note: handlers that catch their own errors (most send/notify handlers) complete normally and never dead-letter — their failures are already logged inside the handler. Dead-lettering captures the "gave up after retries" case (e.g. parse-bank-statement, xbrl ingest) — the genuinely silent-failure path before this existed.
- To review dead-lettered jobs: query
- Graceful restart: the worker drains in-flight jobs for up to 2 minutes on SIGTERM; the API readiness probe returns 503 during shutdown so the LB pulls it from rotation. The worker also flushes buffered Sentry events before exit.
Observability — Sentry
- Default state: OFF. Sentry initialisation is a guarded no-op until
SENTRY_DSN(API + worker) andNEXT_PUBLIC_SENTRY_DSN(web) are set. Dev, CI, and preview builds run with no Sentry traffic. See.env.examplefor the full var list. - To enable in production: stand up the self-hosted Sentry instance, create a project, and set
SENTRY_DSN+NEXT_PUBLIC_SENTRY_DSN(usually the same project DSN) in the server environment + the web build env.NEXT_PUBLIC_*is baked at build time, so the web service must be rebuilt after setting it. Optionally setSENTRY_TRACES_SAMPLE_RATE(0..1; default 0 = errors only) andSENTRY_RELEASE. - What is captured: API 5xx errors (via the
error-handlerplugin), workerpg-bosserrors + dead-lettered jobs, and web server/client errors. 4xx client errors are not captured. PII is scrubbed twice — Pino log redaction plus a SentrybeforeSenddenylist (packages/observability/src/sentry.ts) — before any event leaves the process. - Not yet wired (follow-ups):
withSentryConfigfor web source-map upload (needsSENTRY_ORG/SENTRY_PROJECT/ auth token), and a Prometheus/OTel exporter behind the@breezycorp/observabilitymetrics facade. An external uptime monitor should poll/health/live+/health/readyindependently of Sentry.