Architecture

September 20, 2026 · View on GitHub

How the pieces fit, what the model is asked, and the rules that keep it honest. For setup see the README.

Stack and roles

PieceRole
Postgres (wal_level=logical)Source of truth
zero-cacheReplicates Postgres to every client; realtime and multiplayer come from this
Hono on Node (apps/api)Zero mutate and query endpoints, GitHub sync, TypeSafe worker, dev auth
React (apps/web)Dashboard; reads the local replica, writes through mutators
@typesafe-ai/sdk (server only)Classification and duplicate reranking
celld cells (apps/reviewer)Guided reviews: a run cell drives the pi SDK; a snapshot cell per commit

Clients never talk to GitHub or TypeSafe. They read Zero rows and call mutators; the server side of a mutator is where classification work is enqueued.

Data model

repo            id ("owner/name"), sync state and cursors, questions_version, knobs
                (batch_size, cadence_ms, budget_tokens, sync_limit, pull_limit,
                pull_history_limit, paused), token counters
issue           GitHub node id, number, title, body, state, author, labels, counts,
                timestamps, reclassify, classifying
label           per repo
issue_label     join
pull            like issue, plus draft, refs, diff stats, files, requested reviewers,
                review_decision, mergeable
pull_review     one row per review (reviewer, state, submitted_at)
reviewer        derived roster per repo: reviews, approvals, directories, median
                first-response hours, open load; rebuilt in code after each history page
classification  one row per (subject, kind, questions_version, run): value, confidence,
                probabilities_json, model
feedback        append-only human answers: (subject, kind, value, user, note)
triage          queue state per issue: status (open | done), claimed_by, done_by
run             one row per TypeSafe request: counts, tokens, latency, model, status
presence        one row per browser tab: user, repo, issue being viewed, updated_at
worker_state    per repo: in_flight, dirty, pending, counters, last_error
user            dev accounts: id, name, color

Effective value of a field for an issue = the latest feedback row for that kind, else the latest classification row for that kind at the repo's current questions_version. Human rows never overwrite model rows; the two are reconciled at read time (packages/schema/src/effective.ts). Disagreement between them is visible and feeds calibration.

Flows

Sync

POST /api/sync {owner, name} answers 202 and streams progress through the repo row. Issues are walked newest first, one page per transaction, so recent work lands and gets classified first. A recent phase covers the last year (or down to the previous high-water mark on a re-sync); a slower history phase backfills older pages and is resumable. sync_limit caps how many new issues are kept; updates to stored issues always apply.

With a GITHUB_TOKEN, a GraphQL walk fetches open pull requests (files, reviews, requested reviewers) and a second walk fetches merged and closed ones for reviewer history. After every history page the reviewer roster is rebuilt in pure code; bots and AI review accounts are excluded.

Classify

One scheduler per repo in apps/api/src/worker. A poke while a request is in flight only sets a dirty flag; pokes inside a short collect window share one run; a finished batch re-runs after cadence_ms while work remains. Every request is one shared state plus an independent matrix of questions, and every answer is stored with the questions_version it was asked under.

Per issue, questions v2 asks:

FamilyPrimitiveOutcomes
categoryChoicebug, feature, question, docs, chore, other
areaChoicethe repo's most-used labels + none (skipped when there are no labels)
severityScorecosmetic, degraded, blocks a workflow, data loss or outage
urgencyScoreno hurry, this week, now
duplicateChoiceup to five candidates found by title similarity in code + none
actionChoiceask_author, answer, investigate, decide, accept, close, wait
missingChoicerepro steps, versions, expected vs actual, minimal example, logs, proposal, none

action is the single next step a maintainer should take and drives the Triage grouping; wait is its no-match outcome. missing is asked speculatively for every issue and read only when the next step is to ask the author. The shared state carries the repository, its area labels, and up to twenty recent human-labelled examples (category, area, next step) so the model follows the team's conventions.

Per open pull request, in batches of at most eight: review_effort (a Score over four rubric levels) and reviewer (a Choice over up to five candidates the code shortlisted by directory overlap, recency and volume, plus none). The model judges expertise only; spreading suggestions across people by load happens in the browser over the stored probabilities and never re-asks.

Policy

decide() in packages/triage/src/decide.ts turns answers into decisions with named thresholds from policy.ts: category and area auto-apply at 0.6, the next step at 0.45, duplicates surface at 0.7 and are never auto-closed. Below a threshold the issue lands in Unsure with a reason. Confidence is the concentration of the model's distribution, not correctness.

Priority is a user-weighted sum of severity, urgency, reactions, comments and age; changing the weights changes only the view. Pull requests use a fixed attention order.

Feedback and the queue

feedback.set appends a row and, by default, flags the subject for reclassification so the model's rows refresh with the new example in state. A confirmation passes reclassify: false. triage.claim, triage.setStatus and triage.accept manage the queue; accept records the model's next step, category, area and duplicate as human feedback and marks the issue done in one step.

Feedback is append-only and keyed by a client-generated id, so concurrent edits never conflict: the newest row wins for display and every row stays in the history. Repo knobs are last-writer-wins through Zero's rebase.

Recalculate

"Re-ask" flags the issues in view. "Recalculate everything" bumps questions_version so every issue gets new rows; old rows stay for comparison. The worker also bumps the repo's version to the code's QUESTIONS_VERSION when the code moves ahead.

Guided review

POST /api/reviews queues a guided_review row and runReviewJob drives it in stages (apps/api/src/review/stages.ts). The reviewer cell loads snapshots of the repository at the merge base and at the head and builds the unified diff itself, since GitHub refuses a pull request diff past 300 files or 20,000 lines; jev then answers four questions per changed file (packages/triage/src/review/files.ts, stored in guided_review_file, one run row per batch). The agent names the steps in one short call; jev assigns every file to a step in one request; the agent writes each step's text in parallel, reading the snapshot through list_files, read_file, grep and a jev-backed rank_files. A step's text is one paragraph on its purpose plus inline comments (bug, question, consideration, nit) anchored to diff lines; the model cites them from a line-numbered rendering of the diff, every anchor is validated against the diff, and a bad one goes back for one retry. A tool that fails answers the model with its error, so the model can change course. Each cell request works for about a minute of turns and then hands back with the conversation stored in the cell's SQLite; the API asks again until the stage answers, so no request outlives celld's handler budget. Steps unchanged since the previous review are kept. Every stage streams through the row's phase and groups_json. There is no degraded path: a review needs jev and the reviewer cell, generation is refused up front when either is missing, and any stage failing fails the review with its reason. The screen at /pulls/$pullId/review reads the row, its file rows and everyone's review_progress marks through Zero, and fetches the patch from /api/reviews/:id/patch.

Traces

One OpenTelemetry trace per review, with the standard SDKs on both sides. The API (apps/api/src/review/otel.ts) runs @opentelemetry/sdk-trace-node with the stock OTLP protobuf exporter and @arizeai/openinference-core's tracer: a root agent span per review with the review as session.id and the requester as user.id set on the context, so the OpenInference tracer copies them onto every span beneath; a chain span per stage; and a chain span per jev request (jev.<kind>: items, questions, price) holding the LLM span that @arizeai/openinference-instrumentation-typesafe records for the SDK call, with the state, the answers and the token counts. The SDK namespace is patched by hand at startup because the API is ESM; the worker's classification calls get the same LLM spans as roots. The cell runs packages/openinference-workers, the same stack arranged for workerd: an AsyncLocalStorage context manager, a fetch exporter, and a request span per stage whose export rides on the Durable Object's waitUntil. Every model turn is an LLM span with messages, tools, tokens and cost; every tool call is a tool span. The trace crosses the API→cell hop and the rank_files callback as a W3C traceparent header injected and extracted with the OpenTelemetry propagator, so the reranker span sits under the tool call that asked. Everything ships to PHOENIX_COLLECTOR_ENDPOINT; without it no provider is registered and the tracers are no-ops.

Presence

A heartbeat mutator every 15 seconds records which issue each tab has open. Rows older than 45 seconds are ignored; rows quiet for ten minutes are swept.

Cost

Roughly 200 input tokens per question including the amortised state; issue bodies are the bulk. Twenty issues with seven questions each is about 40K input tokens in one request. Pull batches are capped at eight because files and candidate histories are heavier. Every request writes a run row, and per-repo token counters feed the spend shown in the UI when prices are configured. A per-repo budget_tokens stops the worker when reached.

Guardrails

  • Secrets and the TypeSafe SDK live only in apps/api. Provider keys are sealed at rest and reach the reviewer cell only inside a request; the cell never stores them.
  • One in-flight request per repo; triggers during flight set a flag, never enqueue.
  • Guided reviews never regenerate on their own: a stale review warns and offers Regenerate. Generation is rate limited per person and refused once the repo's review token budget is spent. Removing an invite revokes that person's sessions at once.
  • Every model answer is stored with its questions_version; changing a question means a new version, never editing old rows.
  • decide() and the question builders are pure and covered by canned-answer tests; the only network code is the worker and the sync.