Covers the Python backend of App V5, on Render rather than GCP. Read-only
recon of /Users/alcatraz627/Code/Versable/enhancement-product/backend; no
files were modified and nothing in it was executed.
Evidence: ../evidence/20260817-enhancement-product-recon.md. Source at
/Users/alcatraz627/Code/Versable/enhancement-product/backend. Line
references are to that directory unless stated. Read locally 2026-08-17.
1. What it is#
A Mongo-polling pull queue, not a broker, running as WORKER_COUNT (env,
default 1) multiprocessing worker processes on Render, each one asyncio
loop that claims its own work instead of being pushed to. Four datastores
are live at once here, not a legacy-versus-current split: Mongo holds the
task and item documents a worker claims and writes its outcome onto
(lib/database/__init__.py:24-38), Postgres holds user sessions and the
credit ledger (api/auth_db.py:28, lib/jobs/postgres.py:15-34,
lib/redis/credit_dispatch.py), Redis holds heartbeats, the fleet-wide
retry budget, breaker consensus, the credit-cycle cache and the
credit-dispatch queue, and S3 or local disk (switched by STORAGE_BACKEND)
holds files. Each store answers a different concern, on purpose.
Two kinds of caller reach it. The frontend sends a bearer-ish X-User-Token
header, a base64 blob checked against a Postgres session table
(api/auth.py:142-184, api/auth_db.py:28,36). A second class of internal
caller uses one shared X-Api-Token, compared with hmac.compare_digest
and gated to paths under /internal (api/auth.py:188-206). A separate
TypeScript credit-worker consumes a Redis queue this backend writes to; it
is downstream of this recon, not examined here.
2. Shape of the runner#
Every worker loop iteration checks two Mongo-native queues before doing any
pipeline work: first a cron-like scheduler queue of named, registered tasks
in its own scheduler database (lib/scheduler/scheduler.py:53-296), and
only if nothing there is due, one random job's next pending item task from
the tasks collection, claimed by an atomic find_one_and_update
(lib/tasks/claimer.py:22-162,80-81). There is no separate outbox and no
broker; worker/worker.md:3-10 states plainly that the earlier
Kombu/RabbitMQ design is dead. Once claimed, TaskRunner
(lib/tasks/task_runner.py:46-230) loads the task, run, job, pipeline and
item in five synchronous Mongo reads run through asyncio.to_thread so the
heartbeat keeps ticking (worker/workerv2.py:78-82), validates that the job
is not deleted, has not exceeded its retry limit, and the team's credit
cycle is active, then calls the payload exactly once and writes the outcome
back onto the same task document.
caller ──POST /jobs──▶ api/jobs.py ──insert per-item──▶ Mongo `tasks` Task docs every worker loop iteration polls in this order: ┌── 1. scheduler.scheduled_tasks (Mongo, separate db) ──────────┐ │ Scheduler.pop_task(), named + registered tasks only │ │ │ └── 2. tasks (Mongo), only if nothing scheduled is due ◀─────────┘ AsyncTaskClaimer.claim_random_task() random job first, then find_one_and_update flips pending → running, stamps lockedAt / lockedBy │ ▼ worker/workerv2.py (one of WORKER_COUNT processes, one asyncio loop each) │ ▼ lib/tasks/task_runner.py TaskRunner validate() → process() │ process_pipeline(pipeline, args, item.data, run_id) │ ▼ lib/pipeline/* (payload, one call in) │ outcome written back onto the same Mongo task doc: result / causes[] / errors[] heartbeat: worker ──5s write──▶ Redis (15s TTL) heartbeat_reaper (1-min cron) releases orphaned locks; Mongo active_workers is the 30-min backstop + FE roster credits: deduct_credit step ──LPUSH──▶ Redis queue ──▶ separate TS credit-worker ──▶ Postgres UNIQUE(team_id, cycle_id, credit_key, part_number) + ON CONFLICT DO NOTHING3. Concern by concern#
Rows are canon/00-overview.md. "Vendor" marks mechanisms that only work on
one provider or product. A blank mechanism cell means the recon did not
examine that concern, not that it is absent; guessing at absence is worse
than leaving it open.
| # | Concern | Mechanism here | Cite | Vendor |
|---|---|---|---|---|
| 1 | Runner/payload seam | worker/*, lib/tasks/*, lib/redis/*, lib/breakers/*, lib/database/*, lib/config/*, cron/*, api/tasks.py, api/workers.py, api/redis.py are runner; lib/pipeline/* is payload, invoked through one call | lib/tasks/task_runner.py:319-320 | no |
| 2 | Caller identity | FE: bearer-ish X-User-Token, base64 blob, looked up in a Postgres session table; internal: one shared X-Api-Token, hmac.compare_digest, gated to /internal | api/auth.py:142-184,188-206, api/auth_db.py:28,36 | no |
| 3 | Tenancy | flat OwnerIds{team_id, user_id} on every doc | lib/types/__init__.py:105-111, api/auth.py:17-25,59-85 | no |
| 4 | Roles / RBAC | none inside a team; one hardcoded admin team (Config.ADMIN_TEAM_ID) whose members bypass every team_q()/admin_q() filter | api/auth.py:17-18,62-65,80-85 | no |
| 5 | Submit surface | create_job pre-validates an optional cycle_id against Postgres before the job exists; apply_pipeline inserts one Task doc per item directly into Mongo | api/jobs.py:173-187,225-244, lib/jobs/postgres.py:15-34 | Postgres (cycle check) |
| 6 | Job state ownership | the worker writes outcome directly onto the task doc it claimed; job-level state is a live read, not a caller-mirrored copy | lib/tasks/task_runner.py:234-239,259-266, api/jobs.py:475-487 | no |
| 7 | Derived vs stored status | status lives directly on the task doc, stored rather than derived; job-level rollup is a live Mongo aggregation, not cached on the job doc | lib/types/__init__.py:314, api/jobs.py:475-487 | no |
| 8 | Dispatch / queue | two Mongo-native queues polled every loop iteration; no broker, the old Kombu/RabbitMQ design is confirmed dead | lib/tasks/claimer.py:22-162, lib/scheduler/scheduler.py:53-296, worker/worker.md:3-10 | Mongo |
| 9 | Concurrency | process-level only: WORKER_COUNT processes, one asyncio loop each, no thread/async fan-out inside one item's pipeline run | run_workers.py | no |
| 10 | Retry / backoff | handler owns up to WORKER_RETRIES (default 3), flat 5s sleep; CircuitBreakerOpen/RetryBudgetExhausted short-circuit to deferred with no attempt burned; TaskTerminalError short-circuits straight to failed | lib/tasks/task_runner.py:292-465,341-350,352-396,460 | no |
| 11 | Heartbeat / crash recovery | two-tier: Redis 15s TTL fast path detects a dead worker inside the reaper's grace window; a 30-min Mongo lock-timeout sweep backstops it if Redis itself is down | lib/redis/worker_heartbeat.py, lib/tasks/heartbeat_reaper.py, claimer.py:404-449 | Redis, Mongo |
| 12 | Cancel | pause/resume flips pending to paused to pending; a running task is not killed, it finishes naturally | lib/tasks/admin.py, api/tasks.py:76-88, api/jobs.py:150-170, comments at admin.py:39, api/jobs.py:153 | no |
| 13 | Resume / checkpoint | job and item pause/resume are the only checkpoint states found; no in-pipeline cursor was located in the files read | same as row 12 | no |
| 14 | Idempotency | atomic find_one_and_update on claim and on defer; credit charging keyed on a Postgres UNIQUE(team_id, cycle_id, credit_key, part_number) plus ON CONFLICT DO NOTHING, the repo's own notes call this deliberate, not a workaround | claimer.py:80-81,285-390, lib/redis/credit_dispatch.py, .claude/notes/credit-flow.md:52-54 | Mongo, Postgres |
| 15 | Storage of inputs | Mongo items, item_context_rows, files/files_local; STORAGE_BACKEND (s3 default, local alternative) switches which files collection is used, to keep the two kinds of doc from mixing | lib/database/__init__.py:24-38 | S3 or local disk |
| 16 | Storage of outcomes | structured causes[] (typed CauseRecord) alongside legacy free-text errors[], on the task doc | lib/tasks/errors.py:101-114, task_runner.py:234-239,259-266 | Mongo |
| 17 | Results reporting | a job-level aggregate rollup exists as a live query; a paginated per-item results endpoint was not located in the files read | api/jobs.py:475-487 | no |
| 18 | Logs per job and item | one PrintLogger sink to stdout everywhere, per the repo's own house rule; legacy loguru/print_log sites migrate incrementally on touch; no per-item log stream a caller can read | lib/logging/, CLAUDE.md:44-59 | no |
| 19 | Tracing | Sentry centralized to one init, after the repo's own history of four separate inits; Langfuse spans stamped with user_id/team_id on every LLM call in the pipeline | lib/sentry/__init__.py, lib/langfuse/__init__.py, task_runner.py:305-311 | Sentry, Langfuse |
| 20 | Usage metering | the worker is credit-unaware except its final deduct_credit step, which LPUSHes to Redis for a separate TypeScript credit-worker to charge against Postgres | lib/redis/credit_dispatch.py, .claude/notes/credit-flow.md | Redis, Postgres |
| 21 | Limits / quotas | every claim checks whether the team's credit cycle is active right now, via a Redis cache-aside read in front of Postgres, with negative caching and a tombstone race-guard | task_runner.py:133-228, lib/redis/cycle_bucket.py, api/auth_db.py:69-97 | Redis, Postgres |
| 22 | Caching | several independent layers at different scopes: per-process in-memory (job-deleted, 30s), Redis-shared (credit-cycle bucket), a Mongo/Redis-backed KV, and a 15s cache decorator on token validation | lib/jobs/lifecycle.py, lib/redis/cycle_bucket.py, lib/database/__init__.py:89-101, api/auth_db.py:27 | Redis, Mongo |
| 23 | Rate limiting outbound | not examined | ||
| 24 | Config | one Config class, every field through a require_env helper with defaults; no scattered os.getenv calls found in the files read | lib/config/__init__.py, .env/.env.example | no |
| 25 | Secrets | values reach the process the same way ordinary config does, through require_env from the environment; no separate secrets-manager mechanism (reference injection, rotation) was found in the files read | lib/config/__init__.py | no |
| 26 | Human in the loop | not examined | ||
| 27 | Completion signalling | a cron sweep counts run progress and notifies on completion; it deliberately shares its 1-minute schedule with the heartbeat reaper, because Render cron creation is heavy | cron/notify_jobs.py, comment at cron/notify_jobs.py:99-101 | no |
| 28 | Capability discovery | nothing comparable to a self-documenting registry endpoint was found in the files read | ||
| 29 | Versioning | no contract, module or payload schema version was found; the closest thing is a build-commit dedup key used only for preflight Slack alerts, which is not a caller-facing version | lib/config/__init__.py:79-80,228-237 | no |
| 30 | Health / readiness | GET /health, allowlisted past auth; preflight runs read-only substrate/schema/feature checks on prod boot only and Slack-reports once per (role, git commit) via a Redis dedup lock | api/util.py:28, api/auth.py:139, lib/config/__init__.py:228-237 | Render (commit), Redis (dedup) |
| 31 | Provisioning / environments | Config.ENV plus Render-injected RENDER_GIT_BRANCH/IS_PULL_REQUEST drive environment classification; PR previews auto-namespace the task queue by branch so one Mongo serves preview and base without draining each other's tasks | lib/config/__init__.py:77-93,88-93 | Render |
| 32 | Local dev / debugging | dev worker identity is a fresh token per restart, so the worker roster accumulates dev-only noise; a memory profiler is gated behind an env flag and hard-locked off in production; a chaos-testing flag exists and carries its own inline blast-radius warning | worker/helpers.py:11-16, worker/_memory_profile.py, lib/config/__init__.py:67-70 | no |
| 33 | Output delivery | not established separately from storage of inputs; the same STORAGE_BACKEND switch appears to serve outputs too, but this was not confirmed against a delivery-specific code path | lib/database/__init__.py:24-38 | not confirmed |
| 34 | Data retention | not examined | ||
| 35 | Multiple versions | workers claim only tasks whose names they have registered, so a mixed-version fleet cannot have an older worker steal a task it cannot run | lib/scheduler/scheduler.py:276-286 | no |
| 36 | Conformance | not examined | ||
| 37 | Outputs and exports | a request-time transform chain, not a stored document: JobOutput.output(*transforms) folds decorated functions over the item list and recomputes on every call behind a 60s Redis cache. The pipeline is strictly one task per item, so every N-to-M change happens in the output builder instead (split_json_list, split_rows and fragment_rows expand, merge_rows_range collapses), which is what decouples row counts from credit charging. An older ExporterManager path still persists an Export document, but it now calls the newer layer, so it is a persisting wrapper rather than a second implementation | backend/lib/jobs/formats/types.py:130-173, formats/functions.py:170,337-359,427,569, backend/lib/exporter/__init__.py:159-160, ../evidence/20260818-data-model-split/enhancement-product.md | no |
| 38 | Data ownership split | three stores, two codebases, no cross-database foreign keys. Postgres is Drizzle-owned by the Next.js frontend and holds billing, identity and templates; the backend owns zero Postgres tables and reads exactly four frontend tables by raw SQL. MongoDB is backend-only and holds everything the pipeline touches, with a second read-only deployment for AutoCare and PCDB reference data. Redis is shared transport under three disjoint key prefixes. No single process writes more than two stores | frontend/docs/tech/system/db-stack.md:10,19,20,227,266-269, backend/api/auth_db.py:30,87,120,128, ../evidence/20260818-data-model-split/enhancement-product.md | Postgres + Mongo + Redis |
4. Runner vs payload#
Mostly clean, with real leaks named rather than smoothed over.
All of worker/*, lib/tasks/*, lib/redis/*, lib/breakers/*,
lib/database/*, lib/config/*, cron/*, and the api/tasks.py,
api/workers.py, api/redis.py routes are runner. The payload is
lib/pipeline/*, invoked through exactly one call:
process_pipeline(pipeline, pipeline_args, item.data, run_id)
(lib/tasks/task_runner.py:319-320). That single-call boundary is the
strongest evidence the seam is intentional; the runner does not know what a
pipeline step is.
Four leaks, all acknowledged in the code rather than hidden:
lib/tasks/task_runner.py:145 imports get_active_cycle_for_team from
api/auth_db, the runner reaching into api/, normally the payload/HTTP
layer, for a Postgres read. The inline comment justifies it as avoiding an
"api to lib to api circular" at module load time, a real dependency-direction
inversion, deliberate rather than accidental.
Payload code raises runner-typed exceptions to steer runner-level routing:
@resilient, CircuitBreakerOpen, RetryBudgetExhausted are runner
concerns, but they are raised from inside pipeline methods and caught by
task_runner.process()'s inner loop (task_runner.py:341-350). This is a
documented contract (lib/tasks/errors.py:1-25), not an accident, but it
means payload authors must import runner types.
worker/scheduler_tasks.py:32-35 registers a scheduled task that calls
PipelineManager.run_pipeline(**args) directly, a second entrypoint into
payload code that runs through the cron-like scheduler queue instead of the
item-level claim, lock, retry and heartbeat machinery. It does not get the
claimer's lock semantics, the retry budget, or heartbeat-reaper coverage.
deduct_credit is shaped as an ordinary pipeline step, payload-shaped, but
its actual effect (dispatching to a Redis queue for a separate
credit-worker) is runner- and billing-adjacent, a payload-shaped method
carrying a runner-scoped side effect.
5. Deliberate decisions#
Mongo-poll over broker, and the migration is stated as finished, not
in-progress. worker/worker.md:3-10 flags the earlier Kombu/RabbitMQ
design as dead and warns readers off stale docs or PRs that still mention
it. This instance never routes a task through a message broker.
Two-tier heartbeat is a design choice, not an accident of two systems
existing. Redis is the source of truth for second-by-second liveness;
Mongo carries the historical roster (lib/redis/worker_heartbeat.py:8-9).
The 30-minute Mongo lock-timeout is an explicit backstop for when Redis
itself is the thing that is down.
Credit idempotency lives in a database constraint, not app-level dedup.
.claude/notes/credit-flow.md:52-54 states plainly that ON CONFLICT DO NOTHING is the design, not a workaround, and asks future readers not to
re-litigate it.
Quota checks are deliberately lazy. The same note
(credit-flow.md:44-50) documents a pre-job check plus an async idempotent
charge, with no synchronous budget check at pipeline-apply or task-dispatch
time. That is a named speed/complexity/correctness trade, with the residual
risk window written down rather than left implicit.
Breaker-outermost, retry-innermost ordering is fixed by a decorator, not
left to callers. lib/breakers/decorator.py:1-11 locks the order per
resilience4j convention specifically so callers cannot invert it.
Claim-time fairness picks a job before it picks a task. distinct on
job_id first, then find_one, so no single large job can starve every
other job's items out of a worker's attention (claimer.py:80-81).
deferred is a real outcome, distinct from retry and from failure.
CircuitBreakerOpen and RetryBudgetExhausted route to a deferred state
that costs the item no attempt at all (task_runner.py:341-350), a third
category most of the estate does not have.
Queue namespacing by git branch gives PR previews zero-manual-config
isolation on one shared Mongo instance (lib/config/__init__.py:88-93).
One cron job deliberately carries two unrelated jobs, notification and
the heartbeat reaper, because Render cron creation is heavy and the cadence
happens to match (cron/notify_jobs.py:99-101).
6. Lapses#
Written against canon, not as bug reports.
Caller identity (2) is the same open weak point it is everywhere else in
the estate, in a slightly different shape. One shared X-Api-Token covers
every internal/machine caller (api/auth.py:188-206), compared with
constant-time equality but with no per-caller identity, no revocation short
of rotating the single value for everyone, and no JWT or IdP path at all for
machine-to-machine calls.
Roles (4) do not exist below the team boundary. Ownership is a flat
team_id plus user_id, and the one privileged role is a single hardcoded
admin team whose members bypass every scoping filter
(api/auth.py:17-18,62-65,80-85). Canon leaves roles as app-owned and
recommended, but this instance has no role concept to hand a module scopes
from.
The runner/payload seam (1) has two real leaks alongside its clean
single-call boundary. The dependency-direction inversion at
task_runner.py:145 and the second, unguarded entrypoint into payload code
at worker/scheduler_tasks.py:32-35 both mean scheduled pipeline runs can
bypass the claimer's lock semantics, the fleet-wide retry budget, and
heartbeat-reaper coverage, the exact protections the item-task path was
built to guarantee.
No per-item log stream a caller can read (18), same gap as
versable-runner. PrintLogger writes to stdout only; usage-style
sidecars comparable to versable-runner's were not found for this instance.
Canon calls 18 open, and this instance contributes no answer either.
No versioning at all (29). No contract version, no payload schema version, no module version a caller can read. The only build-identity signal found is a commit-based Slack dedup key for preflight, which is internal tooling, not a caller-facing surface. speedway and walmart-mvp both report a build commit to callers; this instance was not found to.
No capability discovery (28). Nothing comparable to versable-runner's
self-documenting GET /usage was located in the files read.
No per-item paginated results endpoint (17) was located, only a job-level live aggregate. Canon settles pagination and per-item fetch as mandatory on every list; whether this instance actually lacks it, or the recon simply did not find it, was not resolved.
7. Unproven#
- The circuit-breaker fleet-consensus mechanism
(
lib/breakers/consensus.py) and its design doc were referenced by several files but not opened. The quorum/threshold behavior is inferred fromlocal.py's docstring and two config vars only, not verified againstconsensus.pyitself. lib/breakers/notify.pyandlib/breakers/status.pywere not opened. Their existence and role are taken fromlib/breakers/__init__.py's module docstring.lib/pipeline/manager/__init__.pyandlib/pipeline/processor/__init__.pywere located and sized but not read line by line. The single-call boundary claim in section 4 is confirmed only from its call sites intask_runner.py, not from the function's own implementation.- The frontend BTS doc's path moved from
boring-technical-stuff/totech/at some point; the current file (frontend/docs/tech/backend/workers/_index.md, updated 2026-06-17) was found and read only after the originally cited path came up missing.worker/worker.md's own pointer to the old path is stale. lib/jobs/__init__.pyis roughly 73KB (JobsManager), grepped for specific call sites only, not read in full. Job creation, deletion, and pause/resume internals are known only from theirapi/jobs.pycall signatures.lib/jobs/formats/cache.py(job_output_cache) was referenced via its invalidation call sites but not opened; its caching mechanism is inferred only from env var names.- No RBAC table beyond
team_id/user_idplus one hardcoded admin team was found in the files searched. Roughly 35lib/submodules (lib/agents/,lib/matchers/,lib/providers/,lib/scraper/,lib/importer/,lib/exporter/, among others) were not opened at all; this is an absence in what was searched, not a certainty that nothing exists anywhere in the payload-side tree. - Rows 15, 16, 33 vendor marks (S3 vs local disk).
STORAGE_BACKENDswitching the files collection name was read; whether output delivery genuinely shares that same switch, rather than a separate path, was not confirmed. - Nothing here was executed. Every claim is read from source. No job was submitted, no worker was started, no pipeline was run.
8. Retrospective: the assumptions this system was built on#
Owner-stated on 2026-08-18, recorded here so the instance carries its own
reasoning and not only its mechanisms
(../evidence/20260817-owner-answers.md §11).
- As few services as possible. Render plus Vercel, and every new capability added to an existing service. This bought one runner, one auth, one credits system, one observability stack, one deploy, and it meant no capability was ever reimplemented. It cost architectural headroom: "what we had was what all we had", one capability's resource profile became everyone's ceiling, and a new shape needed an architectural change.
- Composability in-system. The pipeline could compose steps, but only its own steps. Integrating the external extractor, with its own job lifecycle, fought the model instead of plugging in. Under the contract, an outside system is a module with the same surface as an inside one.
- The pipeline shape was the only shape. When M output rows were needed
for N input rows, "the pipeline straight up did not support" it, and the
job output layer was built. That layer then carried exports and on-the-fly,
non-pipeline modifications, and let pipeline rows re-run or fail at their
own pace while outputs read whatever was settled. The owner's summary,
"mutable heavy agentic worker run plus declarative output transform run",
is now
../canon/15-outputs-and-transforms.mdand../guides/01-outputs-as-a-transform.md. - A particular split of responsibility and source of truth across UI,
BFF, backend API, and the hand-rolled worker scheduler: Postgres for
user-facing data, Mongo for system I/O and processing, with job templates
the owner names as misplaced and configuration "always hanging around in a
weird place". The data-model split across this and the sibling apps is
being examined for
../guides/02-data-ownership.md(../evidence/20260818-data-model-split/).
The general lesson is in ../guides/00-service-granularity.md: build the
seams before you need them. Every seam in the contract is one this system
needed after the fact.