Skip to main content

Backend pipeline

How a hardware take becomes a clinical recordings row. This page is the internal-engineer reference for the SATE backend: the Supabase edge functions (device-api, process-device-session, finalize-session) and the Cloudflare Container that holds the long AI call (cf-processor/). Everything below is grounded in the code in react_app_sate-ui_update/supabase/functions/, cf-processor/, and cloudflare/.

Project SATE, ref zlgdpivcbmaodgokkdvz, https://zlgdpivcbmaodgokkdvz.supabase.co.

device-api ≥v15Supabase EdgeCloudflare Containerasync state machine

1. Overview — why processing is ASYNC

:::danger The single most important rule in this pipeline Never run the AI transcription from an edge function or a plain Worker fetch. :::

The old process-device-session edge fn ran fetch(AI_PROCESS_URL) (ngrok → self-hosted CUDA box) and awaited the whole transcription. Supabase Edge has a hard ~150 s wall-clock limit — not a timeout we set, not configurable. On a long take the platform kills the worker mid-fetch, before the try/catch runs, so process_error is never written and the session hangs in processing forever. A 32-min take was observed stuck for 70 minutes (edge kill → cron retry → kill → …) before a retry happened to land during a short window. The AI is not slow; the serverless request simply cannot hold the call. A plain Cloudflare Worker has the same problem via its ~100 s origin timeout (524).

The fix: processing is a state machine on sate_device_sessions.status. The long AI call is moved out of the edge entirely into a long-lived Cloudflare Container (cf-processor/), which has no wall-clock. New rows default to queued; the container claims them, holds the AI call, and hands the light half (analysis + insert) back to a fast edge fn (finalize-session).

Edge wall-clock
~150 s
Plain Worker 524 timeout
~100 s
Observed stuck take
70 min
Container wall-clock
none

The state machine (sate_device_sessions.status)

State columns added by the async_processor_state_machine migration: status, processing_started_at, attempts, worker_id, heartbeat_at. New device sessions become status='queued' automatically (column default) — no device-api change is needed to enqueue.


2. Components

The pipeline is deliberately split so no single request ever has to hold the long AI call.

ComponentWhereDeploy / runtimeJob
device-api edge fnreact_app_sate-ui_update/supabase/functions/device-api/index.ts (≥v15)Supabase Edge, verify_jwt:falseThe whole device + app REST surface. Path-routes by auth header (device key vs user JWT). Accepts chunked uploads, assembles + verifies the WAV, inserts sate_device_sessions (status=queued), fire-and-forgets process-device-session.
process-device-session edge fn…/supabase/functions/process-device-session/index.tsSupabase Edge, verify_jwt:falseMust be a 200 no-op in prod. device-api still pings it, but it must NOT process — that would race the container. The repo copy is NOT the no-op (see §7).
finalize-session edge fn…/supabase/functions/finalize-session/Supabase Edge, verify_jwt:falseThe light half: resolve patient, countErrors + calculateSpeechAnalysis, INSERT recordings, set status=done + recording_id. Fits the 150 s limit because the AI already ran in the container. Idempotent.
cf-processor Containercf-processor/app/processor.py (loop), app/main.py (HTTP), src/index.ts (Worker + DO)Cloudflare Container, sate-processor.longcao.workers.devLong-lived process, no wall-clock. Poll loop: requeue_stale_sessions()claim_next_session() (SKIP LOCKED) → download WAV → HOLD the ngrok /process call → copy WAV to recordings bucket → POST finalize-session. On failure → fail_session (never deletes device audio).
Worker /tickcf-processor/src/index.tsCloudflare Worker fronting the Container DODoes NOT process. A single fetch boots/keeps-warm the singleton container. TICK_SECRET-gated.
pg_cron + pg_netSupabase SQL (cron.schedule('sate-processor-tick', '* * * * *', …))PostgresPings the Worker /tick every minute so the container stays warm and drains the queue even with no traffic.

Component topology — device-api enqueues; the container is the only place that holds the long AI call; finalize-session writes the light half back; pg_cron→Worker /tick keeps the container warm:

How the container stays alive

cf-processor/app/main.py starts the poll loop() on a daemon thread and serves a trivial HTTP endpoint on $PORT (any request resets the container idle timer). src/index.ts sets sleepAfter = '20m'; the singleton container sleeps if /tick pings stop and reboots on the next tick, resuming straight from the queue. The container's own loop does the draining — the cron tick is just a keep-warm and a correctness backstop.

pg_cron /tick
every 1 min
Container sleepAfter
20 m
POLL_INTERVAL
10 s
STUCK_MINUTES
45
MAX_ATTEMPTS
3

Poll-loop tunables (env, injected by src/index.ts into the Python process):

vardefaultmeaning
STUCK_MINUTES45reclaim a processing job stalled beyond this (watchdog)
MAX_ATTEMPTS3after this many tries a stuck/transient job → error
POLL_INTERVAL10seconds between empty-queue polls
WORKER_IDcf-container-1claim owner id written to worker_id

The container's per-attempt view of the same status machine (processor.py): a Transient failure (network / 5xx / 408 / 429) requeues with backoff while attempts < MAX_ATTEMPTS, else gives up to error; a Permanent failure (4xx / no segments) goes straight to error; the requeue_stale_sessions() watchdog reclaims a job stalled past STUCK_MINUTES.


3. Connection / auth

Three caller identities hit device-api; the auth header selects the route branch before any DB lookup. Note: the Supabase Edge gateway still requires the public apikey (anon) header on every call regardless of the branch — that is the platform's gate, not the function's.

IdentityHeaderWhoWhat it can reach
Device keyAuthorization: Bearer key-<device-id>The recorder / firmwarePOST /register, GET /devices/:id/commands (heartbeat), POST /sessions{,/raw,/chunk}, GET /sessions/verify, GET /patients
User JWTAuthorization: Bearer <supabase-jwt>Signed-in SLP (web/mobile)everything under the supabase.auth.getUser(token) gate: devices list/claim/rename/delete, commands, patients GET/PUT, sessions GET / audio / retry / delete, firmware, /admin/*
anon apikeyapikey: <anon key>platform gatewayrequired on all calls; not itself an authorization

Two more service-only identities exist for the async back-half:

  • process-device-session and finalize-session accept the service role key OR a PROCESSOR_SECRET in the Authorization header (checked explicitly; verify_jwt disabled).
  • The container authenticates to Supabase REST/Storage with the service role key (SUPABASE_SERVICE_KEY, apikey + Bearer).
  • The Worker /tick is gated on a shared TICK_SECRET (constant string compare) shared with the pg_cron caller.

:::warning verify_jwt MUST stay false On device-api, process-device-session, and finalize-session — the device has no Supabase user JWT; it presents a device key the function validates itself. Redeploying with the MCP/CLI default verify_jwt:true breaks recorder registration and the pipeline. Always pass --no-verify-jwt / verify_jwt:false. :::

Storage buckets

BucketVisibilityHoldsAccess model
device-sessionsPRIVATERaw device-uploaded WAVs + _tmp/ chunk partsserver-side service role only; GET /sessions/:id/audio returns a 302 to a 1-hour signed URL (createSignedUrl(path, 3600))
recordingsPRIVATEFinal WAVs backing recordings rows (manual + device)signed URLs only
firmwarePUBLICOTA .bin releases (sate_<version>.bin)public URL served to the whole fleet by getLatestFirmware
mobilepublicMobile uploads

4. Upload → process → recording data flow

Load-bearing details in the chunk-assembly path (handleSessionUpload, each rule cost a real recording once):

  • Each slice is its own object; the full file is materialised once, on the final slice. The old version rewrote the whole temp blob per slice (quadratic → ~1.5 GB moved for a 30-min take → firmware timeouts → a backlog that could never drain).
  • offset=0 purges the part dir first (sessions are renumbered after a delete; stale higher parts must not be stitched onto new audio).
  • Part dir is scoped by patient (_tmp/<patient>/s<n>) — session numbers restart at 1 per patient, so s1 alone collides.
  • final=1 runs an idempotency probe by (user, serial, session_number, bytes) and confirms the object exists — a lost final ACK becomes a no-op instead of a full re-upload.
  • Contiguity is verified from the listed part sizes before downloading a byte; a gap or a total= mismatch is a 409 and the device restarts from offset 0.
  • A failed storage upload throws (storeSessionRecord) so the device keeps the audio. It used to console.error and insert the row anyway (2xx), which — combined with the 413 file-size bug — destroyed a 62-min recording.

5. device-api endpoints (≥v15)

Prefix /api is stripped (/api/sessions/chunk/sessions/chunk); both /register and /devices/register aliases are accepted.

Device-key routes (Bearer key-<id>)

RouteMethodPurpose
/register, /devices/registerPOSTValidate a one-time claim_token, upsert sate_devices (id = dev-<serial>), return device_key = key-<id>
/devices/:id/commandsGETHeartbeat: update online/last_seen/state/fw/ota_state, drain unconsumed commands, return commands + active_patient + ota; {unclaimed:true} if the row is gone (device resets)
/sessionsPOSTUpload one WAV as wav_base64 JSON
/sessions/rawPOSTUpload one WAV as a raw body (?patient_id&session_number&sample_rate)
/sessions/chunkPOSTChunked slice upload (?offset&final&total&session_number&patient_id&flags); assembles on final=1
/sessions/verifyGETRead-onlystored:true only when the row exists AND objectExists; the recorder's gate before it frees SD audio (fw ≥1.5.13). Never mutates.
/patientsGETDevice fetches its owner's roster (resolves the device's user_id first)

User-JWT routes

RouteMethodPurpose
/devicesGETList the SLP's devices (flips stale rows offline at 45 s)
/devices/claim-tokenPOSTMint a one-time claim-<8hex> provisioning token
/devices/:id/commandsPOST / GETQueue / read remote commands (record, ota, reboot, …)
/devices/:idPATCH / DELETERename / unlink
/patientsGET / PUTRoster read / full replace
/sessionsGETList uploaded sessions (returns status + attempts for real progress, ≥v14)
/sessions/:id/retryPOSTRe-queue an error session (status→queued, attempts→0); only an error may retry
/sessions/:id/audioGET302 → 1-hour signed URL for the raw WAV
/sessions/:idDELETEDelete the row + stored WAV (linked recording left intact)
/firmware/latestGETNewest firmware row (by created_at)
/firmwarePOSTPublish an OTA .bin — validates semver + 0xE9 magic + ≤4 MB (above the admin gate, see §7)
/admin/meGET{ isAdmin } (email in sate_admins)
/admin/devices, /admin/firmwareGETSystem-wide lists (403 unless admin)
/admin/devices/:id, /admin/firmware/:idDELETESystem-wide unlink / delete

The recorder (SATE hardware) uploads with its device key. Plaud and BLE-bridged Pendant sessions upload via a user-authed POST /sessions — they have no sate_devices row / device key, so the session is stored under user.id.


6. Key files

FileRole
react_app_sate-ui_update/supabase/functions/device-api/index.tsThe device + app REST surface (≥v15). Routing, chunk assembly, storeSessionRecord, handleSessionVerify, publishFirmware, admin, triggerProcessor.
…/supabase/functions/process-device-session/index.tsRepo copy still processes (downloads WAV, awaits AI, inserts recordings). Prod is a no-op — do NOT deploy this file (see §7).
…/supabase/functions/finalize-session/The light back-half: analysis + INSERT recordings + status=done.
cf-processor/app/processor.pyThe container poll loop: claim / download / HOLD AI / upload / finalize; Transient vs Permanent retry classification.
cf-processor/app/main.pyContainer entrypoint: daemon-thread loop() + $PORT keep-warm HTTP server.
cf-processor/src/index.tsWorker + ProcessorContainer DO: /tick (TICK_SECRET), /health, sleepAfter='20m', env injection.
cf-processor/README.mdDeploy + pg_cron wiring + tunables.
cloudflare/src/functions/processDeviceSession.tsCF Worker port of the OLD synchronous path (no state machine; fetch(AI_PROCESS_URL) inline). Regression — see §7.
cloudflare/src/functions/deviceApi.tsCF Worker port of device-api (D1-backed).
cloudflare/src/policy.tsTranscribed prod RLS rules (incl. the invite_codes quirk).
cloudflare/schema.sqlD1 schema for the port. Has sate_device_patients unique index; no async state-machine RPCs and no sate_device_sessions dedup unique constraint.
doc/05-backend-supabase.md, doc/06-ai-pipeline.mdCanonical prose references.

7. Known issues & current status

From the full multi-agent audit (2026-07-22). User priorities, in order: (1) never lose or mismatch patient audio; (2) no feature ever stuck; features > security for now. Security items below are noted, deprioritized behind features.

Stuck-state / data-integrity (functional)

IssueSymptomFileStatus
process-device-session repo copy is NOT the deployed no-opThe checked-in file still downloads the WAV, awaits the AI, and inserts recordings. It filters processed=false while the container claims on status, so both process the same session → duplicate recordings + the 150 s edge-kill hang. Prod is deployed as the no-op.…/functions/process-device-session/index.tsOPEN — do NOT deploy the repo file. Make it a real early-return 200 before GA.
Cloudflare port regressioncloudflare/src/functions/processDeviceSession.ts is the OLD synchronous path: no state machine, fetch(env.AI_PROCESS_URL) awaited inline in a Worker, processed=0 filter. A Worker's ~100 s 524 origin timeout would kill a long take exactly like the old edge fn. cloudflare/schema.sql has no claim_next_session/requeue_*/fail_session RPCs.cloudflare/src/functions/processDeviceSession.ts, cloudflare/schema.sqlOPEN — port must adopt the container state machine before it can serve device processing.
storeSessionRecord dedup has no DB backstopThe idempotency probe (user, serial, patient, session_number, bytes) + objectExists dedups a re-uploaded take (lost BLE markSynced ACK). It is the only guard — no DB UNIQUE constraint, so a concurrent double-upload could still slip two rows past the probe.device-api/index.ts (storeSessionRecord)probe FIXED; DB unique constraint OPEN.
cf-processor never heartbeatsThe container claims via claim_next_session but never calls a heartbeat_session RPC during the long AI hold, so a very long take could be requeued by requeue_stale_sessions while still running → duplicate processing. (Reviewer note: a heartbeat guard exists but the container doesn't drive it.)cf-processor/app/processor.py, migration RPC heartbeat_sessionOPEN — call heartbeat_session periodically during process().

Security (noted, deprioritized behind features)

IssueSymptomFileStatus
Heartbeat has no key validation (CRITICAL)GET /devices/:id/commands only checks the header starts with Bearer key-; deviceId comes from the URL path and the key is never looked up. Any key-anything harvests a device's active-patient PHI (name/age/clinician) and marks commands consumed (DoS the real recorder).device-api/index.ts, same in CF portOPEN — noted, deprioritized.
Device key is derivable from the serial (HIGH)device_key = 'key-' + 'dev-' + serial.toLowerCase(). A serial (printed on the device) forges the key → GET /patients returns the owner's whole roster, and session upload injects audio under any patient_id. Reads aren't rate-limited. Fix: mint a random secret at register, store its hash.device-api/index.ts (handleDeviceRegister)OPEN — noted, deprioritized.
POST /firmware above the admin gatepublishFirmware is routed before the subPath.startsWith('/admin') isAdmin() block, so any authenticated user can publish fleet-wide OTA. Server-side image validation (semver + 0xE9 magic byte + ≤4 MB size cap) is in place; the missing piece is the isAdmin() gate. Same in the CF port.device-api/index.ts (publishFirmware)validation FIXED; isAdmin gate OPEN — noted, deprioritized.
invite_codes cross-tenant RLS (MEDIUM)SELECT policy is is_active = 1 unscoped → any signed-in user reads all active invite codes. Transcribed verbatim from prod RLS.cloudflare/src/policy.tsOPEN — noted, deprioritized.
recordings INSERT no patient-ownership checkDevice processing resolves patient_id via resolvePatient (scoped to the SLP's user_id/slp_id, returns null if none), but the INSERT itself asserts no ownership invariant on the resolved id. Combined with the forgeable device key, audio could be attributed across patients.finalize-session / process-device-session (resolvePatient)OPEN — noted, deprioritized.
Root .env holds a live service-role key, not gitignored (MEDIUM)SUPABASE_SERVICE_ROLE_KEY (sb_secret_…) present and git check-ignore .env → not ignored; a git add -A would commit it.repo root .env / .gitignoreOPEN — add .env* to .gitignore and rotate; noted.

Reviewed clean: recordings bucket private + signed-URL capped, constant-time auth compare (cloudflare/src/auth.ts), cf-processor /tick TICK_SECRET-gated, admin gating on the /admin/* subtree.