Skip to main content

Architecture decisions

Synced from bicycle-studio-api

Source: docs/architecture/decisions.md at origin/platform (c2ce9cf), synced 2026-09-26. Do not edit this page here; change the source and run yarn sync:studio.

The decisions that shape Bicycle Studio, numbered as the platform team refers to them (#1–#48), each with the one-line reason. Superseded decisions are kept, marked, because code and status docs still cite the numbers. Where a decision is only partly built, operations.md (in the bicycle-studio-api repository, not published) and storage.md say what is live.

Where things run​

#DecisionWhy
1workflow-runner is its own service (Temporal workers + a Postgres run DB in the existing Cloud SQL). Studio API is the control plane (documents, revisions, validate, plan, publish, approvals, UI, MCP); the runner is the data plane.Runs must survive Studio pod restarts and not share the serving path's failure domain.
2Isolation by node kind (runs_in in the kind registry): control activities on the runner; remote kinds (query, llm, classify, snapshot, send) as activities that call services; compute kinds as one gVisor pod or Job per attempt. Temporal carries control flow only; data travels as blob ids.Untrusted code never runs in a worker process; Temporal histories stay small.
3gVisor for anything running code or content we did not write: code functions on the fn-pool warm pool (one pod per invocation), workflow python/training Jobs, snapshot Chromium. Not for runner workers or remote calls.Tenant code and app-author JavaScript are hostile by default.
4The snapshot worker stays live and unchanged in behaviour until the workflow path is fully proven; target: a render-only backend for the workflow snapshot node, with scheduling and delivery moving into workflows.Customers depend on Schedule Mode today; no big-bang cutover.
5Model training is a workflow extension (a training function or Job node plus a registered model artifact), not a separate system.One executor, one audit trail, one publish gate.
6Workflows are one object type. "Attached" = owned by an app (inherits app permissions, publishes with the app version, deleted with it). Visibility for other apps is by uses of exported artifacts.No second permission model for attached workflows.
11Reads are served by the semantic layer (declared and ad-hoc semantic SQL). data-app-runtime's DuckDB build/serve path is superseded; workflow outputs that apps read are published into the semantic layer as datasets.One query engine, one cache policy, one bill to protect.
12data-app-runtime = server-side execution keyed by runtime_id (rt:app:{app}, rt:wf:{wf}, rt:lib:{tenant} per #28), one immutable deployment per id.An invocation pins exactly what ran.
13The runtime broker is a module inside bicycle-studio-api: resolve, authorize, mint rt-invoke/1, place, broker every outbound capability call (query, agent, llm, fn.call), quotas, depth ≤ 3, tracing.Studio already holds identity, permissions and the audit log; the broker is where policy is enforced.
14Declared functions only, with capability lists; a registry of reusable functions callable cloud-function style from apps, MCP, workflows and agents; executed on the warm gVisor pool.Nothing unlisted is reachable; the same function is one thing on every surface.
15 / 48Studio never imports the runtime library; it calls runtime-server and workflow-runner over HTTP. data-app-runtime is the ONLY executor for functions and workflows. Studio's in-process runner, Jev client, sqlrun copy and vendored spec copies are removed once the runtime path is verified; rollback is git revert plus a values flip, not a kept copy. (#48 supersedes #47's "keep the in-process runner as rollback".)Two copies of validate/plan/execute drift within days (the review found 384 differing lines in validate.py alone).
16Use existing infra: Temporal via scheduling-service's cluster (<temporal host>, namespace default) and the existing Cloud SQL instance (a new database and user, not a new instance).No new operational surface for the platform team.
17 / 30gVisor node pool wf-sandbox-gvisor-spot-v1 (spot, 0→4, n2-standard-8) with namespaces fn-pool, wf-jobs, wf-jobs-preview (PSA restricted, deny-all egress plus an allow to runtime-server:8080 and DNS). The bicycle-runtime-server service account may create and delete pods and jobs there.Sandbox pods reach only the runtime gateway; nothing else.
18Durable claims go behind a store factory (redis first for testing, postgres on the existing Cloud SQL as the target). plat-svc-redis is a cache (single replica, allkeys-lru): only short-lived keys (jti, locks, counters, reservations) are Redis-only by design.An evictable Redis cannot be the guard against a duplicate email.
24CI for data-app-runtime mirrors Studio's: test → build → publish → deploy; platform deploys preview-*, main deploys prod. Agents never merge to main; the user promotes.Preview first, always.
26Postgres = the existing Cloud SQL following agent-service's pattern (one database per service, credentials from the bicycle-ai secret by reference; the service creates its database and non-owner role if missing).One pattern to operate.
35The runner switch is global (BSA_WORKFLOW_RUNNER=remote; inprocess was the rollback), not per tenant. Preview first.Per-tenant executors mean two truths for the same workflow.
47One workflow executor: the runtime's workflow-runner. Nodes that need Studio-held credentials (query, snapshot, function, send, notify) run in the runner and call Studio internal routes with the run grant. Studio keeps orchestration state (records, invocations, approvals, schedules). Partly superseded by #48 (no kept rollback copy).The runner holds no tenant credential; Studio holds no execution code.

Agents and LLM calls​

#DecisionWhy
7Agents are data. agent-service is one generic runtime with platform capabilities (semantic query, detect/explain, connection tools, memory) and declarative checks. Grants = agent spec ∩ app/workflow grant ∩ tenant connections. Writes are never agent tools: they are workflow send steps with approval.A model that can act without a person is the one thing the product must not ship.
8Named Bicycle agents live in the agent-service repo, reviewed and eval-gated; custom agents are declared in app specs or as agent-kind functions and run by the generic custom-agent runner with capped budgets.Tenant logic never lands in a platform service.
9A plain LLM call capability in agent-service (POST /api/v1/llm/completions) with model selection, strict structured output, cost and quotas, and a per-tenant model allowlist. Used by llm functions, workflow llm nodes and agents.One metered door to the models.
20 / 22agent-service is MR-only for agents (no direct merges to platform); Studio, UI, runtime and the snapshot worker may be merged by agents after tests.agent-service serves the whole product; a human gate stays.
25One agent harness (Claude Agent SDK) behind AgentHarness + HarnessFactory; other harness kinds are registered but unimplemented. Same pattern for LLMProvider (Anthropic on Vertex only today).Nothing above the harness imports an SDK.
31 → 40 → 41Tools come from the tenant's connections through tool-service. #31: agents borrow the existing drivers/actions. #40: a driver is a named, reusable tenant record (prompt + bound connections + optional actions), attached like a skill; amended: no driver is needed, an agent can be given read access to a connection directly, and the main UI avoids the word "driver". #41: services stay generic — no per-connector or per-tenant code in agent-service or Studio (the Atlassian driver module and ticket_coverage were removed); safety that used to be connector code becomes generic caps and annotations.Connector know-how belongs to the tenant, not to a deploy.
32Evals are opt-in; nothing is required to publish; "unverified" is neutral. The publish gate applies only when the author turned evals on.Do not block authors on a gate they did not ask for.
33 → 38 → 42No lookup kind. #33 introduced a deterministic read-only "lookup action"; #38 made it a function kind; #42 retired it ("it is JQL-specific"). Reading a connection = an agent with connection access and instructions. Existing lookup drafts stay readable until the EBG copy app moves to an agent; then lookups/* is deleted.One way to read a connection.
43"Functions solid" includes agents-as-tools and chat: functions as agent tools, and Bicycle chat plus data-app chat calling functions with the same invocation and trace. Data-app chat inherits the app's granted functions by default (`chat.functions: inheritoff`).
45Agent limits: custom-agent defaults 30 steps / 80 tool calls / $2.00 / 600 s; platform ceiling 100 / 300 / $10 / 1800 s; per-tenant caps set only by Bicycle staff. Studio signs the effective limits into every run (agent-env/1, Ed25519 over JWKS); agent-service verifies and clamps, else applies defaults and marks limits_unverified.Limits a caller cannot raise, verified where the money is spent.
46 (nested)Nested agent runs share their parent's slot: a run started as a tool call of a live run of the same tenant and person takes no in-flight slot of its own (Studio parent_run_id, agent-service options.parent_run_id); at most 3 levels below a top-level run. Daily budgets and caps still count every run.A parent waiting on its child can never be refused the slot it already holds.

Functions, invocations, visibility​

#DecisionWhy
10Patterns (Udit's scheduled detection) are out of scope. Never modify existing apps. MRs target platform; preview first.Working rules for every track.
21Functionality first; security items are collected in the security backlog and revisited before the prod rollout. Open security MRs stay unmerged or flag-off.Ship the product, then harden the known list (see security.md).
23Visibility: tenant users see only their own tenant's workflows, functions, runs and artifacts, scoped by use case (semantic domain) within the tenant. Bicycle staff (an @bicycle.ai login on the bicycle tenant) additionally see the global library and read across tenants. No cross-tenant copy or promote. Other tenants' objects answer 404, not 403. Tokens and OAuth never get the staff view.A 403 leaks existence; a copy leaks data.
27Runtime credentials work like the snapshot worker's: the runtime never mints. Viewer mode = the caller's downstream credential forwarded by the broker; service mode = a Studio-signed grant exchanged at Studio for a tenant support-login session.No long-lived tenant credential in a sandbox.
28rt:lib:{tenant} is a contract minor for direct library-function calls (including MCP). Functions switched on in preview.Library functions need a runtime id without an app.
29Studio IA: an opened app is just the app plus an unobtrusive Subscribe menu (schedules → runs); manage tabs behind it. Approvals for external recipients deferred; tenant users and groups only.Viewers see the app, not the machinery.
34A classify node backed by typesafe.ai Jev; the API key is a Secret (jev-api-key), referenced by name only. After #48 the only Jev client is in the runtime (runtime-server classify, workflow-runner failure routing).Classification is a service call, not a prompt.
36Workflow emails send as the tenant's service identity, with the approver named in the footer and on the receipt.The approver decided; the tenant sent.
37Functions first, then workflows. Functions (code, llm, agent, classify) are made solid end to end on preview before workflow UI work resumes; a workflow is orchestration of solid functions.Sequencing.
39One invocation and trace model across every surface: every function or workflow run from any surface is one Studio invocation (inv_…, one status machine, input/output/error/cost/timings/caller/surface) with one ordered trace-event schema; the API polls, MCP streams progress from the same events; visibility as for apps (#23)."What ran for tenant X today" is one list.
44Deleting a function or workflow = disable (soft delete). Pinned callers keep working, new uses are refused (409), it leaves every picker; Enable restores; the confirm dialog names dependents. No hard delete.Deleting a callable must not break what already calls it.
46 (notify)Workflow outcome notifications: full success sends directly when the workflow opts in (notify.on_success: send; approval stays the default). Any failure is reported to one recipient: deterministic classes route by a table (config → author, data → data owner, platform → Bicycle ops), ambiguous failures are classified by Jev into the closed set of configured role names (never an address); confidence < 0.7 or error → author. Every routing decision is a trace event.Someone hears about every failed run; nobody is emailed by a model's guess.

Reconciliation decisions (X1–X15, 24 Sep)​

These came out of the reconciliation of the four design documents and are cited by code and status docs.

XDecision
X1Send dedupe: a Redis bridge claim (sched-send:{tenant}:{app}:{sid}:{fire_key}, 35-day TTL) on Studio's legacy deliver route now; after the runner, the ledger family in Postgres dedupes every runner send; Studio never writes the runner's ledger.
X2deploy_env belongs to each trigger, not the tenant; runs, triggers and the run index carry it; versions, pointers, cache and ledger are shared state. Scheduling-service ids wft-{deploy_env}-{workflow}-{trigger} were designed but not built (today: wft_{wf}_{trigger}_{seq}).
X3Every new grant is JWS compact, alg: EdDSA, header {kid, typ}; typ ∈ wf-run/1, wf-render/1, agent-env/1, caller/1, app-grant/1, rt-invoke/1. Only Studio holds the private key; every verifier reads Studio's JWKS. Existing HMAC grants (snapshot run grant, unsubscribe token) stay until migrated.
X4token-service run-as tokens are tenant-wide; authority in v1 is the Studio-signed wf-run/1 grant, enforced on every runner call to Studio. agent-service must verify a caller identity on every request (not yet on; see security.md).
X5Apps read through the semantic layer; publish_dataset replaces a serving pointer file (not built: waits on the semantic-layer dataset publish API).
X6GET /api/studio/v1/runs is a federated read; Studio keeps no separate index table for the runner's runs.
X7A revision has one key: an integer n per workflow, monotone; attached revisions carry {app_version, overlay_rev} as metadata only.
X9The agent workflow kind is runs_in: remote:agent-service, v1.5. (Today "agent" in a workflow is a function node whose ref is an agent-kind function.)
X10Served models are claude-sonnet-5 (default), claude-sonnet-4-6, claude-haiku-4-5, claude-opus-5; allowlists and grant scopes hold concrete ids, never aliases.
X11One event vocabulary from the runner's outbox to Studio: run.*, node.*, approval.*, action.*, pointer.moved, … Studio adds trigger.* and grant.*. alert-service returns no Message-ID, so an unknown send is a person's decision.
X12A runtime_id resolves to one immutable deployment pinned in the grant; the broker mints rt-invoke/1 and places on a runtime-server exec replica; the placing replica owns the invocation; viewer-mode capability calls ride the placement connection (nothing dials into Studio).
X13Studio never imports the runtime libraries (= #15).
X14Existing Temporal and Cloud SQL (= #16); sandbox namespaces deny all egress and enforce PSA restricted; pods set runtimeClassName: gvisor.
X15The store factory (= #18): each backend reports `guarantee: best_effort

Superseded or withdrawn​

  • #33, #38 (lookup action / lookup kind) → #42.
  • #47's "keep the in-process runner as the rollback path" → #48.
  • The design-era "serving pointer file" → X5 (semantic-layer datasets).
  • The dedicated Temporal namespace ask → withdrawn (X14 / #16).
  • The reviewed-tools-only reading of #31 → #40 (amended) and #41.