Skip to content

uid: T-187 title: Telegram receipt bot β€” batch-upload failures (de-warp, cropper, one-message lifecycle) status: done area: accounting created: 2026-07-25 updated: 2026-07-28 owner: girafeev1 related: T-173, T-174, T-176, T-182


T-187 β€” Telegram receipt bot: batch-upload failures

This task doubles as the handover brief for the agent taking over the Telegram bot. Read it end to end before touching code. Written by Records (Infrastructure) on 2026-07-25 at the owner's request: "please, after looking into the issues, draft a handover agent so that the newly delegated agent who's going to be explicitly work on Telegram bot will understand and to have all the information it needs to access places to find out information."

0. Before anything

Read AGENTS.md in full, and attest in every reply (name Β· session URL Β· board checked Β· task). Two rules bite hardest here:

  • Firestore structure changes need the owner's approval FIRST β€” whole document, current vs after, new field shown under its immediate parent, plus the existing fields you considered and rejected. Ask as its own question; a general "go ahead" is not schema approval.
  • Deploys are on-command only. Commit and push; never deploy unless the owner says so in that turn.

Branch policy, merge-via-GitHub-PR, and the task-board mirror (scripts/sync-taskboard.sh) are all in AGENTS.md. Follow them.

1. What the owner reported (2026-07-25, verbatim symptoms)

After a batch upload of receipts through the bot:

  1. "The one message per receipt for the entire lifecycle is not achieved" β€” the T-176 behaviour (one message per receipt that morphs through the lifecycle) did not hold under batch.
  2. Several receipts showed ⚠️ Image De-warp failed (gemini-no-image). The previous attempt is still shown β€” tap Re-run AI again to retry. Owner's hypothesis: "too many receipts were being processed."
  3. Some receipts still failed after several manual retries; some rendered with the green background and no crop at all; some cropped ones kept a sliver of green. Owner's hypothesis: "the cropper might not have enough point to cleanly warp and crop the image."

2. Diagnosis already done β€” start here, it is the strongest lead

gemini-no-image does not retry, and it almost certainly should. lib/inngest/receipt-processor.ts ~line 1006-1013:

const MAX_CLEANUP_ATTEMPTS = 3
for (let attempt = 1; attempt <= MAX_CLEANUP_ATTEMPTS; attempt++) {
  const candidate = await cleaner.cleanImage(bytes, data.mimeType)
  if (!candidate) {
    return { ok: false as const, error: 'gemini-no-image' }   // ← EXITS the loop on attempt 1
  }
  ...drift check, which is what the loop was actually written for...
}

The loop's own comment says it retries on drift. A null image β€” precisely what an overloaded or rate-limited Gemini returns β€” returns immediately without consuming attempts 2 or 3. That matches the owner's report exactly: a batch produces many concurrent Gemini calls, some come back empty, those fail instantly, and the same receipt succeeds later by hand once load has passed.

Likely fix: continue instead of return on a null candidate, fail only after the loop is exhausted, and add backoff between attempts. Confirm against real logs before changing it β€” the reasoning is sound but unverified against a live failure.

Also worth checking while in there: whether batch uploads are concurrency-limited at all on the Gemini call. The web upload modal deliberately runs a 2-parallel queue (components/records/ReceiptUploadModal.tsx documents why); the bot path may have no equivalent cap.

3. Where the code lives

Area Path
Bot entry, handlers, menus lib/telegram/ β€” client.ts, botMenu.ts, auth.ts, categories.ts
Receipt pipeline (bot side) lib/telegram/receiptProcessor.ts β€” cleanup/crop/dewarp orchestration
Document AI path lib/telegram/documentAiReceiptProcessor.ts — dewarp + green→white + rectify
Firestore store + shape lib/telegram/receiptStore.ts β€” TelegramReceipt, metadata.parsedReceipt, paymentMethod, reviewState, settled*
Async job + the failing branch lib/inngest/receipt-processor.ts β€” retries, the user-facing ⚠️ message (~line 1106)
Drive upload lib/telegram/driveUploader.ts
Web equivalents (compare against) components/records/ReceiptUploadModal.tsx, ReceiptDetailDrawer.tsx

4. Where to find out what actually happened

  • Runtime logs β†’ Vercel. AUTHORISED and working as of 2026-07-25 β€” verified by a live list_projects call returning the eop project (prj_6Y57ZalE8BdtcYU57lpuoXDQe953, team team_ne7hiLb7J8wyHgGulNGIxGIz). Use get_runtime_logs and get_runtime_errors against that project. This was the main blocker on diagnosing symptom 2; it is cleared.
  • Inngest owns the async retry lifecycle (lib/inngest/). Its dashboard shows per-run failures and is the fastest way to see how many attempts a given receipt actually got.
  • Firestore is the source of truth for receipt state. Read the receipt doc via getTelegramReceipt rather than a hand-rolled query β€” it is the shape every guard elsewhere expects. Sandbox agents get a Google service-account triple in env (GOOGLE_PROJECT_ID / GOOGLE_CLIENT_EMAIL / GOOGLE_PRIVATE_KEY).
  • You cannot read the owner's Telegram messages. Ask them to paste message text and screenshots.
  • Alert emails are mirrored into the notifications collection (aote-system), so you can read what the owner was alerted about without mailbox access.

5. Prior bot work β€” read these before redesigning anything

  • T-173 fold the fold-note confirmation into the status message
  • T-174 category picker button
  • T-176 one morphing message per receipt β€” symptom 1 is a regression against this; start from what it built rather than rebuilding
  • T-182 (upcoming) will add a /command applied to an already-submitted receipt to flag it as a company-card charge, plus a mandatory auto-match hook at submit time. Owner decided the /command over a caption flag specifically so a typo cannot silently mis-file a receipt. Coordinate β€” it lands in the same message flow.

6. Suggested order

  1. Get Vercel logs authorised; confirm the gemini-no-image path against a real failed run.
  2. Fix the no-retry bug + add backoff; check whether the bot path needs a concurrency cap like the web one.
  3. Then the cropper quality issues (symptom 3) β€” green background surviving, or no crop at all, points at corner detection, which is a separate problem from the Gemini nulls.
  4. Then the one-message-per-receipt regression (symptom 1) under batch.

Symptoms 2 and 3 look related but are probably two distinct faults: an infrastructure/ratelimit fault and an image-geometry fault. Resist fixing them as one.

Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Progress log

2026-07-27 β€” Handover accepted; task taken over by EOP Telegram Bot. βœ… Attestation: read AGENTS.md in full Β· checked the board by scope (this task already covers the bot batch-upload work β€” no duplicate opened) Β· tracking T-187. Source: EOP Telegram Bot Β· https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW

  • The owner named this session EOP Telegram Bot (2026-07-27, verbatim: "Hello, Your name is EOP Telegram Bot.") and delegated the Telegram receipt bot to it; ledger row appended to docs/agents-registry.md in the same commit.
  • The owner attached a local file (telegrambothandover.md) that did not reach the sandbox β€” this task file, the in-repo handover brief, was read end-to-end instead. If the attachment carries anything beyond this file, the owner will paste it in chat.
  • Status flipped todo β†’ doing: the handover is accepted. Next step per Β§6 is confirming the gemini-no-image path against real Vercel runtime logs before touching code. No code changed yet.

2026-07-27 β€” Diagnosis pass against live Vercel telemetry (no code changed). βœ… Attestation: read AGENTS.md Β· board checked by scope (still T-187's scope β€” no dup) Β· tracking T-187. Source: EOP Telegram Bot Β· https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW

A fresh batch ran this morning (2026-07-27 ~07:31–07:37 UTC) and left telemetry. From the Vercel runtime-error table (project eop, long retention):

  • 5Γ— "Vercel Runtime Timeout Error: Task timed out after 60 seconds" on /api/inngest β€” last at 07:34:47Z, i.e. during the batch. The worker invocation is being killed outright under batch load.
  • 291Γ— [tg] editMessageText failed β€” "Bad Request: message to edit not found" on /api/inngest (9 users, first seen 2026-06-16, last 07:37:29Z today). Chronic and predating T-176; the worker keeps editing Telegram messages that no longer exist. Every such miss degrades into fresh messages β€” this is the breadcrumb trail for symptom 1.
  • 2Γ— [tg] editMessageReplyMarkup failed β€” "message is not modified" (webhook; cosmetic).

Why the raw gemini-no-image log line couldn't be captured after the fact: the full-text runtime-log store on this plan retains only ~1 hour (verified: a receipt-image request from 07:55Z was unfindable at 08:56Z; until-bounded historical queries fail with ExceedsBillingLimitError). The [receiptProcessor] lines are console.warn, so they never enter the longer-lived error table. Operational lesson: to capture the Gemini failure subtype (HTTP 429/5xx vs 200-with-no-image-part), logs must be pulled within ~an hour of a batch. The user-visible string itself is still conclusive: ⚠️ Image De-warp failed (gemini-no-image) is emitted only at lib/inngest/receipt-processor.ts:1106 with outcome.error = 'gemini-no-image', which only arises from the null-candidate return at :1012 β€” so Gemini returning no image on the re-run path is confirmed by the owner's own bot messages.

Code corroboration (all three cleanup loops + config): 1. A null Gemini image never retries, anywhere. Re-run path returns on attempt 1 (receipt-processor.ts:1009–1013, the Β§2 lead β€” confirmed as written). Initial-pass split loop deliberately breaks on unavailable (:355). GeminiReceiptProcessor.process() breaks too (lib/telegram/receiptProcessor.ts:986–992, with a comment admitting "a separate cleanImage-level 429 retry could be added later"). And because a null is returned as a successful step value (ok:false / status:'unavailable'), Inngest-level retries never fire for it either. 2. No concurrency cap on the bot path. processReceiptPhoto (receipt-processor.ts:147–152) has no concurrency/throttle config β€” N batch receipts β†’ N parallel runs β†’ ~2–3Γ—N concurrent Vertex calls plus N Cloud Run cropper calls. The web modal caps itself at 2 parallel for exactly this reason (ReceiptUploadModal.tsx). 3. cleanImage's fetch timeout is 60 s (receiptProcessor.ts:1110) β€” equal to the Vercel function limit, so a Gemini call slowed by batch load kills the whole invocation (the 5 timeouts above) before the abort can fire and return a clean null. 4. Symptom 3's mechanism is distinct, as Β§6 predicted. cleanImage ships Gemini's green-canvas output through the Cloud Run cropper and, on cropper non-OK or corner-detection fallback, returns the green image uncropped (receiptProcessor.ts:1228–1242) β€” that is "green background, no crop at all". A "sliver of green" is corner-detection imprecision on the greenβ†’white edge. Batch load also concentrates cropper failures, which is why it presents alongside symptom 2.

Sandbox blockers hit (stopped per classifier guidance, not worked around): the session's permission classifier denied GCP Secret Manager access (both gcloud and the SDK β€” wanted the Inngest dashboard key) and npm install (repo and scratchpad β€” blocks the local npm ci + unfiltered tsc merge gate and Firestore SDK reads). Owner decides: allowlist these, or the type-check gate has to run elsewhere.

Proposed fix plan (pending owner go-ahead; no Firestore schema changes anywhere in it): (a) on a null candidate, consume the loop's remaining attempts with jittered backoff (~2 s/5 s) in all three loops instead of bailing; (b) drop cleanImage's fetch timeout to ~40 s so a slow Gemini aborts cleanly into the null-retry path instead of a 60 s SIGKILL; (c) add a small Inngest concurrency limit (~2–3, mirroring the web modal's cap) on process-receipt-photo β€” to verify at implementation time: waitForEvent must not hold a slot while parked, else long-lived summary cards would starve the queue; (d) then the cropper-geometry work (symptom 3) and the one-message regression (symptom 1) as separate fixes, in that order.

2026-07-27 β€” Fix (a)+(b)+(c) implemented on claude/eop-telegram-bot-handover-em5o1i (PR #934). βœ… Attestation: read AGENTS.md Β· board checked by scope (no dup) Β· tracking T-187. Source: EOP Telegram Bot Β· https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW

  • The waitForEvent question resolved from Inngest's docs before coding: runs parked on step.sleep/step.waitForEvent do not hold a concurrency slot β€” only actively executing steps count β€” so the cap cannot be starved by long-lived summary cards.
  • What landed (details in the commit): null-image retries with backoff in all three cleanup loops (worker paths back off via step.sleep β€” 5 s/15 s β€” costing no function time; process() uses short in-process waits); the re-run path split to one attempt per step (the old shape stacked ~6 model calls in one invocation β€” the 60 s-timeout signature); concurrency: { limit: 2 } on process-receipt-photo (env-overridable via RECEIPT_WORKER_CONCURRENCY); cleanImage fetch timeout 60 s β†’ 40 s with abort/network failures returning null into the retry path instead of throwing.
  • Behaviour notes: retry-on-drift semantics unchanged; a drifted candidate now also survives a later hard failure (surfaced as drift-rejected rather than a dead-end error); in-flight runs that memoized the old rerun-cleanup-{iter} step id re-run cleanup once on replay (tolerated, per T-176's step-shape precedent). Creates documents in the existing shape β€” no Firestore schema change.
  • Type-check gate NOT yet run: the session's permission classifier blocks npm install/npm ci, so the mandatory fresh-npm ci + unfiltered tsc --noEmit merge gate could not run locally. A system-tsc syntax parse of both edited files shows only missing-node_modules noise. Do not merge until the real gate runs (owner: allowlist npm for this session, or run the gate elsewhere).
  • SHAs (append-only): 13e2baf (takeover docs), 58c5808 (diagnosis docs), 835b332 (fix code).

2026-07-27 β€” Symptom 1 confirmed from the owner's chat export; sandbox unblock needs owner action. βœ… Attestation: read AGENTS.md Β· board checked by scope (no dup) Β· tracking T-187. Source: EOP Telegram Bot Β· https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW

  • The owner supplied the Telegram chat export ("RECEIPT YUM YUMMMM", 22–27 Jul). Today's batch: 16 captioned photos sent as one album at 12:06:55 HKT. The message flow shows symptom 1's real mechanism: captioned uploads go through the caption-confirm flow, which T-176 never folded into the one-morphing-message design (T-176 explicitly scoped to the captionless force-reply flow and left the caption flow's separate text ack in place). Observed per receipt: β‘  a "πŸ“ Use this caption as the substantiation note?" reply-message, β‘‘ after Use β€” a separate "Got it + πŸ“Έ Which company…" picker message (two receipts got duplicate picker messages β€” "Taxi from recording studio…" and "Mailing of auditing documents" each appear twice, a webhook double-fire to chase), β‘’ the summary card, β‘£ any ⚠️ De-warp failure as yet another standalone message (13 in the export). So a 16-receipt batch produced ~50+ bot messages. The symptom-1 fix must fold the caption flow (and the multi-subsidiary picker) into the same morphing card, and de-duplicate the picker send β€” coordinate with T-182, which lands in this flow.
  • Also visible: several "Attempt: 2" summary cards clustered around ⚠️ failure runs β€” manual re-runs succeeding once load passed, consistent with the T-187 diagnosis; and one "⚠️ AI cleanup rejected (drift: lineItemsSum) β€” showing your original photo" (drift guard working as designed, Hongkong Post receipt).
  • Sandbox permission classifier β€” definitive outcome: it blocks, in this session: npm ci/install (both repo and scratchpad), GCP Secret Manager access in every form (gcloud, SDK, and a plain-node JWT flow with the env SA triple β€” retried once with the owner's explicit authorization on record), and editing .claude/settings.json (self-granting permissions is a hard boundary β€” correctly so). The GCP-side IAM is not the problem; the block is Claude-Code-side. Owner unblock options (both can be done together): (a) edit .claude/settings.json themselves to add the permission allow-rules
  • the missing sandbox allowedDomains (registry.npmjs.org, oauth2.googleapis.com, secretmanager.googleapis.com, secretmanager.asia-east2.rep.googleapis.com, api.inngest.com); (b) set the GCP_SM_SA_KEY bootstrap env var in the claude.ai/code environment settings β€” the repo's existing SessionStart hook (scripts/fetch-cloud-secrets.mjs) then auto-populates .env.local from the aote-pms-env Secret Manager bundle at session start, no in-session permission needed. That hook found no GCP_SM_SA_KEY in this session, which is why .env.local is absent here.

2026-07-27 β€” MERGE GATE PASSED. Owner's settings edit unblocked the sandbox; lockfile desync found and fixed on the way. βœ… Attestation: read AGENTS.md Β· board checked by scope (no dup) Β· tracking T-187. Source: EOP Telegram Bot Β· https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW

  • The owner committed the .claude/settings.json permission + sandbox-domain change (cd5c24f) on this branch; npm and the Secret Manager endpoints are now usable in-session.
  • Found: npm ci broken on every fresh clone since b4e92f1 (PR #926) β€” package.json and package-lock.json shipped out of sync in that one commit (missing esbuild@0.28.1, yaml@2.9.0, and the esbuild platform packages). This silently blocked the AGENTS.md fresh-install gate for every agent. Fixed in 52889f5 β€” purely additive lockfile sync (530 insertions, 0 deletions; no existing pins changed).
  • The mandated gate then ran clean: NODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit --incremental false on a fresh npm install β†’ exit 0, zero errors. PR #934 is merge-eligible.
  • npm run test (vitest): 713/717 pass. The 4 failures are all in __tests__/pages/api/workspace/billing/ingest.test.ts and share one cause unrelated to this diff: the handler's scrape-heartbeat stamp needs Firestore application-default credentials the sandbox doesn't have ("Unable to detect a Project Id") β†’ 500 instead of 200. Pre-existing, env-dependent; worth a ticket if it also fails in CI elsewhere.
  • Secrets bootstrap verified working: fed the env SA triple to scripts/fetch-cloud-secrets.mjs as its GCP_SM_SA_KEY input β†’ .env.local populated (79 entries, incl. VERCEL_API_TOKEN, RECEIPT_CROPPER_URL/SECRET). The bundle carries no INNGEST_* keys β€” if Inngest run-history access is needed for the next batch diagnosis, the keys live in Vercel's project env (readable via VERCEL_API_TOKEN) or the owner adds them to the bundle. Setting GCP_SM_SA_KEY in the environment config remains the zero-friction path for future sessions.
  • SHAs (append-only): 13e2baf, 58c5808, 835b332, 71d4ab4, cd5c24f (owner, settings), 52889f5 (lockfile sync).

2026-07-27 β€” Symptoms 3 and 1 fixed (owner: "Continue to proceed"). βœ… Attestation: read AGENTS.md Β· board checked by scope (no dup) Β· tracking T-187. Source: EOP Telegram Bot Β· https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW

  • Symptom 3 (36d726f) β€” the green canvas can no longer reach the user. Root cause: the Cloud Run cropper reports an undetectable green edge (x-crop-fallback: true, body = input unchanged) but cleanImage ignored the header and displayed the raw green canvas. New exported trimGreenBorder helper (hue-based border scan β€” the cleanup prompt pins no exact RGB β€” then a sharp extract) now runs on every green-canvas exit: real crops get residual slivers shaved, cropper fallback/non-OK/threw/unconfigured paths get salvaged to the receipt's bounding box (no dewarp, but no green wall), and an essentially-all-green canvas returns null into the T-187 retry/fallback machinery instead of being shown. 6 unit tests on sharp-synthesized canvases (__tests__/lib/telegram/trimGreenBorder.test.ts), including the green-logo-on-paper non-trim case. No cropper-service changes β€” app-side only, rides the normal deploy.
  • Symptom 1 (f712999) β€” captioned batches now keep ONE morphing message per receipt. Root cause from the owner's chat export: the caption-confirm flow sent a separate text prompt and never set promptMessageId, so every later stage (picker, status, summary) sent yet another message (~50 bot messages for the 16-receipt batch). The confirm prompt now rides the receipt photo itself via sendPhotoPromptMessage (generalized from T-176's sendPhotoNotePrompt); with promptMessageId set, the existing morph machinery carries confirm β†’ picker/status β†’ summary in one message. Duplicate-picker fix: proceedAfterNoteCaptured claims the pending doc with a lastUpdateTime precondition before dispatch and before the picker write, so Telegram's slow-webhook redeliveries / double-taps can't dispatch twice or send a second picker. Document-upload fallbacks preserved; nedt (typed-note edit) keeps its force-reply prompt (force_reply can't ride a message edit). T-182's /command lands in this same flow β€” its author should build on sendPhotoPromptMessage.
  • Verification: full unfiltered tsc --noEmit --incremental false clean after each change; suite 719/723 (same 4 pre-existing env-dependent billing-ingest failures; my 6 new tests green).
  • Noted while in there (pre-existing, not fixed): dispatchReceipt swallows inngest.send failures β€” a receipt whose event never reached Inngest dies silently behind its status message. Ticket-worthy.
  • Hygiene: the .env.local fetched earlier for the gate was deleted after use (it is gitignored via .env*.local; values never printed). Remaining symptom-1 polish for a later pass: the worker's ⚠️ De-warp failure notice still arrives as a separate message (rarer now that null images retry).
  • Owner runtime-verify (after next deploy): batch-upload captioned receipts β†’ per receipt exactly ONE bot message that walks confirm β†’ (picker) β†’ status β†’ summary; no green backgrounds; no duplicate pickers; failures retry silently with "image service busy" frames before any ⚠️.

2026-07-27 β€” Codex review round on PR #934: both P1 findings real, both fixed (1779b09). βœ… Attestation: read AGENTS.md Β· board checked by scope (no dup) Β· tracking T-187. Source: EOP Telegram Bot Β· https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW

  • Finding 1 (correct): the dedup claims were snapshot-gated, not state-gated. A callback redelivered after the claim write reads the fresh updateTime, so the lastUpdateTime precondition alone passes it β€” it only excluded same-snapshot racers. Claims in proceedAfterNoteCaptured now also gate on the awaiting state ('dispatching' / 'subsidiary' = already claimed β†’ return), and handleSubsidiaryPick β€” which had the same unguarded readβ†’dispatchβ†’delete shape (pre-existing) β€” gains the identical claim.
  • Finding 2 (correct): process()'s inline null-retry could bust the 60 s ceiling when the first attempt burned the 40 s fetch abort. New INLINE_NULL_RETRY_BUDGET_MS (15 s): the loop only resamples while the invocation is young β€” fast quota nulls still retry; slow timeouts fall straight to the OpenCV-only path. (Worker paths were already per-step and unaffected.)
  • Gates re-run clean: unfiltered fresh-deps tsc --noEmit --incremental false exit 0; 719/723 tests.
  • Meta: PR #934 flipped ready-for-review at 10:38Z with the full three-symptom body (the earlier GitHub rate limit cleared). main moved to 71e9db6 (T-188, another agent) β€” only overlap is an IDENTICAL package-lock.json sync (they hit the broken npm ci too); merge state stays clean.
  • SHAs (append-only): + 36d726f (symptom 3), f712999 (symptom 1), fe6c786 (docs), 1779b09 (review fixes).

2026-07-28 β€” CLOSED: PR #934 merged to main (47008de). done per policy β€” merged, NOT deployed. βœ… Attestation: read AGENTS.md Β· board checked by scope (no dup) Β· closing T-187. Source: EOP Telegram Bot Β· https://claude.ai/code/session_019z4CYS2vGnufAXZNDmnBJW

  • Verdict: all three reported symptoms addressed and merged. Symptom 2 (de-warp failures under batch): null-image retries with backoff, per-attempt steps on the re-run path, concurrency: 2 on the worker, 40 s Gemini timeout β€” confirmed against Vercel telemetry (5Γ— 60 s timeouts, batch of 2026-07-27) before coding. Symptom 3 (green background): x-crop-fallback honored + trimGreenBorder on every green-canvas exit β€” 6 unit tests. Symptom 1 (one message per receipt): caption-confirm prompt rides the photo card via sendPhotoPromptMessage, morph machinery carries the whole lifecycle; state-gated lastUpdateTime claims kill duplicate dispatch/pickers (hardened after two confirmed-real Codex P1s, both fixed in 1779b09). Verified by: full unfiltered fresh-install tsc exit 0 after each change; 719/723 vitest (4 pre-existing env-dependent failures); 6 new unit tests green.
  • Trigger for the merge: owner observed the second-message behaviour still live (2026-07-28) β€” correct, since the fix was merged nowhere and deployed nowhere. Merge closes gate 1. ⚠️ DEPLOY STILL PENDING β€” production behaviour will NOT change until the owner explicitly orders a deploy (owner policy: deploys are one-off, on-command). After deploy, runtime-verify per the 2026-07-27 checklist above. In-flight pendings created pre-deploy lack promptMessageId and keep the old text-prompt flow for their remainder β€” expected, not a regression.
  • Blast radius for other agents: Telegram webhook message flow (caption-confirm now a photo card; sendPhotoPromptMessage is the shared mechanic β€” T-182's /command should build on it); Inngest worker step layout (per-attempt rerun-cleanup-{iter}-aN steps, new clean-image-backoff-N sleeps, concurrency: 2); cleanImage output contract (never returns the green canvas; null on all-green); pending-doc awaiting gains claim states (dispatching) β€” value is write-only today, but don't repurpose it. package-lock.json synced (also landed independently via T-188). No Firestore schema changes anywhere.
  • Loose ends already ticketed in this file: dispatchReceipt swallows inngest.send failures (silent receipt loss β€” ticket-worthy); worker ⚠️ De-warp notice still a separate message (polish); Gemini failure subtype capture needs a within-the-hour log pull after a batch; Inngest keys absent from the secrets bundle.
  • SHAs (append-only, final): 13e2baf, 58c5808, 835b332, 71d4ab4, cd5c24f (owner), 52889f5, 36d726f, f712999, fe6c786, 1779b09, c76b11a, 47008de (merge), + the docs commit carrying this close-out.