Hosted shunt — architecture
Hosted shunt — architecture
Section titled “Hosted shunt — architecture”Status: Active design. Approved decisions are listed first. Open questions at the end are for future work, not blockers.
gondolier is a hosted, multi-tenant version of
shunt — the batch-then-bisect merge queue
for Forgejo/Gitea. It manages tenant forge connections, encrypted credentials,
and queue execution while reusing the shunt engine’s stage → gate → bisect
mechanics unchanged.
Infrastructure stack (confirmed)
Section titled “Infrastructure stack (confirmed)”| Layer | Choice | Rationale |
|---|---|---|
| Compute | Cloudflare Workers (Go/WASM) | Free tier: 10M req/day, 100 CPU sec/day. Serverless, scales to zero. Same platform as pokomplete. |
| Database | Supabase PostgreSQL | Free tier 500MB. Managed. Auth integration. Same as pokomplete. |
| Secrets | Cloudflare Secrets (master key) | Serverless KMS. Zero infra cost. ~$0. |
| Encryption | Envelope AES-GCM (Go crypto) | Per-tenant DEK encrypted by master key. Never stored in plaintext. |
| Auth | API keys per tenant | Simplest. No auth infra to build. Supabase SSO v2. |
| Scheduler | Workers cron + Durable Objects | Serverless scheduler. Per-tenant lease via DO. |
| Resend | Same stack as pokomplete. Generous free tier. | |
| Analytics | PostHog | Same stack. |
| Queue state | Cloudflare KV | Fast transient storage. Engine state re-derivable from forge API. |
Why not self-hosted: laputacloudco’s portfolio runs on managed services. Self-hosted OpenBao is for personal infra secrets only — not acceptable for customer data. We’re cost-sensitive until revenue covers infra costs.
What doesn’t change
Section titled “What doesn’t change”The core algorithm and forge mechanics are unaffected by hosting:
- Batch-then-bisect merge queue: N ready PRs land in one CI run when green;
a failing batch bisects in
log₂(N)runs to isolate the bad PR(s). - No bot commands — a PR joins the queue via the forge’s native “merge when checks succeed”.
- Landing goes through the forge’s own merge endpoint after setting a required commit status; branch protection (required status + push allow-list) is the actual enforcement point, not shunt’s own state.
- Queue membership and batch state are largely re-derivable from the forge API each cycle. This statelessness is the main asset carried into a hosted design — in-flight batches survive a crash or restart without a WAL.
Everything below is about turning “one process, one forge, one bot token”
into “many tenants, many forges, isolated credentials,” by importing
github.com/rbtr/shunt/internal/engine unchanged and providing a Workers I/O
abstraction layer.
Tenancy model
Section titled “Tenancy model”Unit of isolation is (tenant_id, forge_instance). A tenant may point at
more than one forge instance (self-hosted + a secondary), and a forge
instance is never shared for credential purposes across tenants even if two
tenants happen to point at the same URL (e.g. two customers on the same
Codeberg-alike).
tenant ├─ forge_connection (instance URL, bot identity, encrypted token) │ ├─ managed_repo (owner/repo, base branch, status context, merge style) │ └─ managed_repo ... └─ forge_connection ...managed_repo is the direct analog of what manager.Manager builds today
from SHUNT_TOPIC discovery or SHUNT_REPO, just persisted instead of
recomputed from env vars at startup.
Onboarding & auth
Section titled “Onboarding & auth”Forgejo/Gitea act as OAuth2 providers (RFC 6749 Authorization Code Grant, PKCE, refresh tokens, OIDC discovery). Onboarding:
- Tenant creates or designates a bot account on their forge instance.
- Auth exchange — two paths:
- OAuth2 authorization code grant, where scoped. On shared public
instances like Codeberg, we register one OAuth2 application ourselves
(
/user/settings/applications, done once bylaputacloudco), and every tenant on that instance authorizes through a standard “Sign in with Codeberg”-style consent redirect — no token ever changes hands. On self-hosted tenant instances, someone registers the OAuth2 application there once (/admin/applications, or automated viaPOST /api/v1/user/applications/oauth2given one initial admin-scoped setup token) — a one-time cost per instance, not per tenant or repo. - Scoped Personal Access Token paste, as a fallback where OAuth doesn’t help (see caveat) or where the tenant prefers not to register an OAuth application at all.
- OAuth2 authorization code grant, where scoped. On shared public
instances like Codeberg, we register one OAuth2 application ourselves
(
- Whichever path, shunt encrypts and stores the resulting credential, then
calls
EnsureBranchProtectionon each opted-in repo. - Tenant adds the
mq/**-scoped gate workflow to their repo (unchanged — runs on the tenant’s own Actions runners, so hosted shunt never needs CI compute).
Caveat, verified against current docs: Forgejo’s OAuth2 provider has
no scopes implemented yet — a token obtained via OAuth “will have
administrative rights” on the authorizing account, full stop (source:
forgejo.org/docs/latest/user/oauth2-provider/). That’s broader than a
repo-scoped PAT, not narrower — so on Forgejo/Codeberg specifically, OAuth
today is a UX win but a blast-radius regression versus a scoped PAT. Gitea
1.23+ has since shipped granular OAuth2 scopes (read:repository,
write:repository, etc.), so on Gitea-hosted tenants OAuth is a strict
improvement — better UX and a tighter, revocable, refreshable grant.
Net design: default to OAuth2 only where it is genuinely scoped (Gitea 1.23+, and any future Forgejo release that ships OAuth2 scopes); fall back to scoped-PAT paste everywhere else, including Codeberg and self-hosted Forgejo today. Surface which path a given instance gets, and why, rather than presenting OAuth as uniformly safer than it currently is.
Data model
Section titled “Data model”Supabase PostgreSQL. Three categories:
- Tenant config (durable, low write volume): tenants, forge connections, managed repos, per-repo settings. The only state that must never be lost.
- Encrypted credentials: forge tokens (PAT or OAuth access+refresh token pairs), envelope-encrypted with a KMS-backed key (Cloudflare Secrets master key → per-tenant DEK), never logged, never returned plaintext.
- Operational/audit log (durable, append-only, disposable): batch history, bisection steps, land/bounce events — useful for dashboard/support but not required for correctness.
Execution model
Section titled “Execution model”Workers cron trigger fires every 5 minutes. Each invocation processes one tenant’s repos sequentially:
- Acquire a per-(tenant, repo, base) lease via Durable Object (short-TTL).
- Fetch tenant config + encrypted token from Supabase (REST, service role).
- Decrypt token from Cloudflare Secrets (master key → DEK → token).
- Run engine tick (imported from
github.com/rbtr/shunt/internal/engine). - Update queue state in KV if needed.
- Write result to audit log in Supabase.
- Release lease.
If a tick exceeds 30s Workers timeout, the result is partial — the next cron tick retries and the engine re-derives from forge API (the re-derivation property handles this gracefully). This is acceptable because engine state isn’t persisted; it’s re-derived from the forge each cycle.
A per-repo lease (Durable Object) ensures at most one tick per repo at a time, avoiding double-staging without global coordination.
Multi-forge rate limiting
Section titled “Multi-forge rate limiting”- A per-
(tenant, forge_instance)limiter via Cloudflare KV (sliding window). - Backoff on 429s/5xx scoped to the failing connection only.
- One tenant’s noise cannot degrade another tenant’s queue latency or burn another tenant’s forge API rate limit.
API surface
Section titled “API surface”REST API on Workers (net/http routing). Operations:
POST /api/v1/tenants— create tenantGET /api/v1/tenants— list tenantsGET /api/v1/tenants/:id— get tenantPUT /api/v1/tenants/:id— update tenantDELETE /api/v1/tenants/:id— delete tenantPOST /api/v1/tenants/:id/connections— add forge connectionGET /api/v1/tenants/:id/connections— list connectionsPUT /api/v1/tenants/:id/connections/:cid— update connection (token rotation)DELETE /api/v1/tenants/:id/connections/:cid— remove connectionPOST /api/v1/tenants/:id/connections/:cid/repos— add managed repoGET /api/v1/tenants/:id/connections/:cid/repos— list managed reposDELETE /api/v1/tenants/:id/connections/:cid/repos/:rid— remove managed repoGET /api/v1/tenants/:id/queue— get queue stateGET /api/v1/tenants/:id/repos/:rid/batch— get current batchGET /api/v1/tenants/:id/audit— get audit log (paginated)
All endpoints require X-API-Key header (per-tenant API key stored in Supabase).
Encryption model (envelope)
Section titled “Encryption model (envelope)”Cloudflare Secret (master key, ~512 bit) ↓ AES-GCMPer-tenant DEK (256 bit, stored encrypted in Supabase) ↓ AES-GCMForge token (PAT string or OAuth access/refresh pair, stored as JSON blob)- Master key stored in Cloudflare Secrets (set by operator, never in repo).
- Per-tenant DEK: random 256-bit key, encrypted with master key, nonce stored alongside encrypted DEK in Supabase.
- Forge token: encrypted with DEK, nonce stored alongside in Supabase.
- Decryption: master key (Secrets) → DEK (DB) → token (DB). Done in-memory during engine tick only. Never logged, never returned by API.
- Token rotation: encrypt new token with same DEK, atomically swap in Supabase. Old token invalid at next tick. No overlap window.
Notifications (v1)
Section titled “Notifications (v1)”Tenant-provided webhook URL on repo config. POST JSON on bounce:
{ "event": "bounce", "tenant_id": "...", "repo": "owner/repo", "pr_number": 42, "pr_title": "fix: something broken", "pr_head_sha": "abc123", "reason": "status check failed: ci/build", "timestamp": "2026-08-03T12:00:00Z"}v2: Slack/Discord incoming webhooks (just POST to different URL), email (Resend).
Product surface
Section titled “Product surface”- REST API — tenant management, queue inspection, bounce notification.
- Web UI — v2. Same operations via HTML, no separate auth system.
- Notifications — webhook on bounce (v1), Slack/Discord/email (v2).
Security & secrets
Section titled “Security & secrets”- Tenant forge tokens are the highest-value secret — compromise gives push access + merge rights on tenant repos. Envelope encryption at rest; decrypt only in-memory in the worker tick; never log.
- Per-tenant isolation: a worker tick holds at most one tenant’s decrypted token in memory. KV rate limits are per-(tenant, forge_instance).
- Audit log records credential access (without raw values), tenant changes, and forge API errors.
Billing
Section titled “Billing”Natural metering unit: CI runs saved = (PRs merged) - (gate workflow triggers)
per billing period. Directly defensible “here’s what you paid for” number.
Migration path from self-hosted
Section titled “Migration path from self-hosted”A self-hosted deployment moves to hosted without changing forge-side state (branch protection, merge-queue status context, gate workflow are all forge-side artifacts). Migration is “point config at hosted scheduler instead of local process.”
Import tool reads old env vars (SHUNT_REPO, SHUNT_TOPIC, SHUNT_TOKEN) and creates equivalent tenant/repo/connection records via the API.
Open questions
Section titled “Open questions”- Pricing model details (per-repo vs. per-merged-PR vs. per-batch).
- Whether to offer managed bot-account provisioning flow (requires org admin scope from tenant) vs. requiring tenants to bring their own bot account.
- Multi-region: whether tenants care about data residency for the audit log.
- Web UI framework: Go templates (simple, one binary) vs. separate SPA (separate deploy, but richer UX).
- Billing integration (Stripe): when do we add it?