Agent docs

enhancement-product (App V5)

Covers the Python backend of App V5, on Render rather than GCP.

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 NOTHING

3. 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.

#ConcernMechanism hereCiteVendor
1Runner/payload seamworker/*, 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 calllib/tasks/task_runner.py:319-320no
2Caller identityFE: 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 /internalapi/auth.py:142-184,188-206, api/auth_db.py:28,36no
3Tenancyflat OwnerIds{team_id, user_id} on every doclib/types/__init__.py:105-111, api/auth.py:17-25,59-85no
4Roles / RBACnone inside a team; one hardcoded admin team (Config.ADMIN_TEAM_ID) whose members bypass every team_q()/admin_q() filterapi/auth.py:17-18,62-65,80-85no
5Submit surfacecreate_job pre-validates an optional cycle_id against Postgres before the job exists; apply_pipeline inserts one Task doc per item directly into Mongoapi/jobs.py:173-187,225-244, lib/jobs/postgres.py:15-34Postgres (cycle check)
6Job state ownershipthe worker writes outcome directly onto the task doc it claimed; job-level state is a live read, not a caller-mirrored copylib/tasks/task_runner.py:234-239,259-266, api/jobs.py:475-487no
7Derived vs stored statusstatus lives directly on the task doc, stored rather than derived; job-level rollup is a live Mongo aggregation, not cached on the job doclib/types/__init__.py:314, api/jobs.py:475-487no
8Dispatch / queuetwo Mongo-native queues polled every loop iteration; no broker, the old Kombu/RabbitMQ design is confirmed deadlib/tasks/claimer.py:22-162, lib/scheduler/scheduler.py:53-296, worker/worker.md:3-10Mongo
9Concurrencyprocess-level only: WORKER_COUNT processes, one asyncio loop each, no thread/async fan-out inside one item's pipeline runrun_workers.pyno
10Retry / backoffhandler 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 failedlib/tasks/task_runner.py:292-465,341-350,352-396,460no
11Heartbeat / crash recoverytwo-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 downlib/redis/worker_heartbeat.py, lib/tasks/heartbeat_reaper.py, claimer.py:404-449Redis, Mongo
12Cancelpause/resume flips pending to paused to pending; a running task is not killed, it finishes naturallylib/tasks/admin.py, api/tasks.py:76-88, api/jobs.py:150-170, comments at admin.py:39, api/jobs.py:153no
13Resume / checkpointjob and item pause/resume are the only checkpoint states found; no in-pipeline cursor was located in the files readsame as row 12no
14Idempotencyatomic 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 workaroundclaimer.py:80-81,285-390, lib/redis/credit_dispatch.py, .claude/notes/credit-flow.md:52-54Mongo, Postgres
15Storage of inputsMongo 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 mixinglib/database/__init__.py:24-38S3 or local disk
16Storage of outcomesstructured causes[] (typed CauseRecord) alongside legacy free-text errors[], on the task doclib/tasks/errors.py:101-114, task_runner.py:234-239,259-266Mongo
17Results reportinga job-level aggregate rollup exists as a live query; a paginated per-item results endpoint was not located in the files readapi/jobs.py:475-487no
18Logs per job and itemone 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 readlib/logging/, CLAUDE.md:44-59no
19TracingSentry 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 pipelinelib/sentry/__init__.py, lib/langfuse/__init__.py, task_runner.py:305-311Sentry, Langfuse
20Usage meteringthe worker is credit-unaware except its final deduct_credit step, which LPUSHes to Redis for a separate TypeScript credit-worker to charge against Postgreslib/redis/credit_dispatch.py, .claude/notes/credit-flow.mdRedis, Postgres
21Limits / quotasevery 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-guardtask_runner.py:133-228, lib/redis/cycle_bucket.py, api/auth_db.py:69-97Redis, Postgres
22Cachingseveral 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 validationlib/jobs/lifecycle.py, lib/redis/cycle_bucket.py, lib/database/__init__.py:89-101, api/auth_db.py:27Redis, Mongo
23Rate limiting outboundnot examined
24Configone Config class, every field through a require_env helper with defaults; no scattered os.getenv calls found in the files readlib/config/__init__.py, .env/.env.exampleno
25Secretsvalues 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 readlib/config/__init__.pyno
26Human in the loopnot examined
27Completion signallinga 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 heavycron/notify_jobs.py, comment at cron/notify_jobs.py:99-101no
28Capability discoverynothing comparable to a self-documenting registry endpoint was found in the files read
29Versioningno 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 versionlib/config/__init__.py:79-80,228-237no
30Health / readinessGET /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 lockapi/util.py:28, api/auth.py:139, lib/config/__init__.py:228-237Render (commit), Redis (dedup)
31Provisioning / environmentsConfig.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 taskslib/config/__init__.py:77-93,88-93Render
32Local dev / debuggingdev 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 warningworker/helpers.py:11-16, worker/_memory_profile.py, lib/config/__init__.py:67-70no
33Output deliverynot 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 pathlib/database/__init__.py:24-38not confirmed
34Data retentionnot examined
35Multiple versionsworkers claim only tasks whose names they have registered, so a mixed-version fleet cannot have an older worker steal a task it cannot runlib/scheduler/scheduler.py:276-286no
36Conformancenot examined
37Outputs and exportsa 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 implementationbackend/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.mdno
38Data ownership splitthree 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 storesfrontend/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.mdPostgres + 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 from local.py's docstring and two config vars only, not verified against consensus.py itself.
  • lib/breakers/notify.py and lib/breakers/status.py were not opened. Their existence and role are taken from lib/breakers/__init__.py's module docstring.
  • lib/pipeline/manager/__init__.py and lib/pipeline/processor/__init__.py were located and sized but not read line by line. The single-call boundary claim in section 4 is confirmed only from its call sites in task_runner.py, not from the function's own implementation.
  • The frontend BTS doc's path moved from boring-technical-stuff/ to tech/ 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__.py is roughly 73KB (JobsManager), grepped for specific call sites only, not read in full. Job creation, deletion, and pause/resume internals are known only from their api/jobs.py call 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_id plus one hardcoded admin team was found in the files searched. Roughly 35 lib/ 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_BACKEND switching 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.md and ../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.

@versable-git/ui · reference, canon, and method, read in place