Architecture

Operator·11 minutes to read

One page before you touch anything: the seven packages, how a database query is allowed to touch a tenant's row at all, the four database roles, the shape of a run, the two meters, and the migration numbering convention that keeps parallel work from colliding.

The seven packages

Each package has exactly one owner and each is imported by something else — apps/cli, apps/web and apps/worker are hosts; the packages are where the logic lives.

  • @botseon/core — the policy engine (decide()), the permission-tag vocabulary (PERMISSION_TAGS, see docs/reference/permission-tags.md), the rule compiler that turns an approval into a saved always-allow rule (or refuses to), the memory model, envelopes and cards, and the shared error types. No I/O, no database.
  • @botseon/db — the only package that opens a database connection. Migrations, the schema lint (packages/db/src/lint.ts), the jobs table, the append-only ledger (appendEvent/verifyChain), and — the one thing everything above depends on — withOrgScope/withPrivileged (below).
  • @botseon/models — model route resolution (which route a bot's call is allowed to use under its organisation's residency policy) and the usage meter: what a call actually cost, in tokens and micro-cents, at a pinned route price.
  • @botseon/module — pure types and validation: defineModule, DataFlow, the module-API stability policy. No I/O, no database, no network, because a third party imports this package to write a module, not to run one. See docs/reference/module-manifest.md.
  • @botseon/providers — the four provider seams (auth, events, blobs, keys) and the driver that ships for each in the home edition. See docs/reference/provider-seams.md.
  • @botseon/runtime — the run loop itself: executeRun, approvals, the reviewer, memory compaction, and the tools every run has regardless of which module it eventually loads.
  • @botseon/billing — the credit meter: periods, accounts, the append-only ledger, the pre-flight check and the draw. Apache-2.0 framework code in its own right, not a driver slot — see Provider seams for why it is a package and not a seventh seam.

The scope path

Application code reaches a tenant's row through exactly one of two functions, both in packages/db/src/scope.ts — plus a third, narrower one for a single already-open transaction (below):

/** The only way application code touches tenant rows. Transaction-local role and GUCs. */
export async function withOrgScope<T>(
  sql: Sql,
  scope: Scope,
  fn: (tx: Sql) => Promise<T>,
): Promise<T> {
  if (!UUID.test(scope.orgId) || !UUID.test(scope.actorUserId)) throw new Error('invalid scope');
  return sql.begin(async (tx) => {
    await tx.exec('set local role botseon_app');
    await tx.query(
      "select set_config('app.org_id', $1, true), set_config('app.actor_user_id', $2, true)",
      [scope.orgId, scope.actorUserId],
    );
    return fn(tx);
  });
}

withOrgScope opens a transaction, switches role to botseon_app for that transaction only (set local role, not set role — it cannot leak past the transaction), and sets the two GUCs the row-level-security policies read (app.org_id, app.actor_user_id). withPrivileged is the same shape for cross-organisation work — provisioning, sweepers, the CLI's own reads — switching to botseon_privileged instead and requiring a non-empty reason string, which Task 6's audit trail records.

withPrivilegedInScope (UX-1 round 1, R1) is the third: it escalates an ALREADY OPEN withOrgScope transaction to botseon_privileged for one call and restores botseon_app before returning, rather than opening a second transaction the way withPrivileged does — the one sanctioned way to do cross-member work that must commit or roll back with the state change that triggered it.

Nothing else may set a role or a scope GUC. The gate greps apps/* and packages/* for set role, set session authorization and set_config(..., false) outside this one file (its own tests are the sanctioned exception, since they exercise the fail-closed path on purpose), and apps/cli is inside that grep exactly like apps/web and apps/worker — a database-backed CLI command opens its own scoped transaction the same way a server action does, never its own role switch.

The four database roles

rolewhat it can dowhat it holds
botseon_appreads and writes tenant rows, filtered by row-level security keyed on app.org_id/app.actor_user_idnologin — nothing connects as it; the runtime set local roles into it
botseon_privilegedcross-organisation reads and writes, gated by _priv policies and an audited reasonnologin, same as above
botseon_runtimenothing of its own — no table privilege, no inherited privilege (noinherit)login (LOGIN and a password are given by the migrate step, not a migration — a migration file is committed text and a password is not); set true, inherit false membership in both roles above, which is what lets it set local role into either one inside a transaction and nothing outside one
botseon_migratorDDL: creates tables, roles, policies, grantsnologin, nobypassrls, createrole; member of neither application role

The app and the worker connect as botseon_runtime — a login role that, on its own, can do nothing: a query issued outside withOrgScope/withPrivileged fails with permission denied for table … instead of quietly returning every tenant's rows, which is what a superuser connection did before migration 0011. Migrations run as botseon_migrator (or, until a deployment's migrate step is pointed at it, still as the database administrator — see below).

The migrator's read-nothing property is a property of the grants, not a hard guarantee. botseon_migrator owns every table it creates, and every tenant table in this schema is created with force row level security — which subjects even the table's owner to the policies. No policy anywhere names botseon_migrator, and a table with RLS forced and no applicable policy denies by default, so select * from bots as this role returns zero rows, not an error. That is a property of the schema, checked directly against the live catalogue (m1c-migrator.test.ts), not an assertion taken on faith. But the role owns those tables, and an owner may alter table … disable row level security (or … no force row level security) on a table it owns — so a deliberate migration, written by someone with commit access, can still read whatever it likes. The property removes the accidental read — a select left in a backfill, a \dt that scrolls tenant rows into a CI log — and it does not stop the malicious one. What it is worth is what the review of a migration is worth, and a migration that must read tenant rows in order to write them has to say so in its header and enter botseon_privileged explicitly for that read (migrations are excluded from the scope-path grep for exactly this reason).

botseon_migrator exists as of migration 0031, nologin — giving it LOGIN and a password, and pointing a deployment's migrate step at it instead of the database administrator, is not done yet (the plan's known gap). Until that lands, migrations still run as the administrator, which is strictly more than botseon_migrator can do, not less — the property above describes what the role will guarantee once it is the one actually running them.

The run loop's shape

executeRun (packages/runtime/src/run.ts) executes one attempt at a run: claim it, load the bot and the conversation under the run actor's own scope, freeze a compliant model route, then drive the tool loop while lifecycle callbacks write the ledger, the meter and the checkpoint. Every database touch goes through withOrgScope (or withPrivileged for the one read that precedes the scope), and every state transition is published on org:<orgId>:run:<runId> over the event bus — which is how the web app's run view updates without polling. It returns an outcome rather than throwing: the worker decides whether the job is retryable from the outcome's reason (completed, approval, budget, no_route, policy_denied, model_error, … — never from the text of an error message, because that text is not a stable contract).

The two meters and the transaction they share

Two packages meter a model call, for two different reasons, and they write in the same transaction so the two rows they produce commit or abort together — verified against packages/runtime/src/run.ts, where the wiring actually lives.

  • @botseon/models's recordUsage writes usage_records — what the call actually cost, in tokens and micro-cents, at the route's pinned price. One row per (run_id, step, call_seq), and it is written whether or not anyone is billed for it: even a BYOK or local-model call gets an estimated cost recorded, labelled as an estimate.
  • @botseon/billing's drawForUsage draws against the organisation's credit balance, keyed on the credentialKind @botseon/billing's credentialKindFor computed for the call (platform draws; byok and local — a framework deployment draws nothing, D40/D41 — do not).

recordUsage and drawForUsage run back to back inside the run loop's onStepEnd handler, in the same withOrgScope transaction as saveCheckpointrecordUsage first, its returned id keying credit_ledger.usage_record_id, so a ledger row can never commit without the usage row that justifies it and the two abort together on a retried step.

@botseon/billing's preflight is a third call, not part of that transaction, and it does not run once per model call the way the other two do — it runs before a call, at two different points, because the tool loop's own stop condition (stopWhen) only evaluates after a step has already finished:

  • once before the loop's first call, since nothing earlier in the run has established headroom for agent.generate to check;
  • at the tail of onStepEnd, after that step's own draw has committed — but only when the step that just finished actually leaves work to continue (stepResult.finishReason === 'tool-calls'; a step that ended in a plain-text reply has no next call for a pre-flight to matter to, and checking anyway risks discarding a real answer to pause a run that was already about to stop on its own).

A reviewer or compaction-summary call (meterSecondaryCall) is metered the same way but classified independently: credentialKindFor runs again against that call's own resolved route, not the primary call's credentialKind, because a reviewer route can genuinely differ in residency and provider from the run's own route, and billing a self-hosted reviewer call as platform (or the reverse) would be wrong regardless of what the main call was.

A credit draw is never an approvable action: spend (the permission tag) is about a bot spending the member's money in the world, and a model call is not that (see docs/reference/permission-tags.md).

Migration numbering

Every migration file is NNNN_name.sql, numbered forward-only and never edited once applied (0000–0019 already shipped: M0 owns 0000–0013, M1a 0014–0019). Each milestone reserves its own range so parallel work never races over a file name: M1b owns 0020–0029, this milestone (M1c) owns 0030–0039 — 0030, 0031 and 0032 are in the tree today — and M3 owns 0040–0049. A task needing a table adds it to a migration file its own milestone already owns rather than claiming a number outside its range.

Restrictive owner policies

A restrictive _owner policy whose USING clause carries an is null visibility arm must state its WITH CHECK explicitly. Postgres reads an omitted FOR as ALL and, with WITH CHECK omitted, reuses USING as the write arm — so an arm written to keep a pseudonymised row visible to the organisation that has to keep it silently becomes a licence to write one. The write arm is the read arm with the is null disjunct removed: with check (member_user_id = app_actor_user_id() or app_is_admin(org_id)).

M2 hit this shape four times: spend_limits_owner (migration 0100), deletion_requests_owner (0103) and member_tool_interests_owner (0104), each corrected before it shipped, and 0030's credit_periods_owner, credit_accounts_owner and credit_ledger_owner, which shipped in M1c and are corrected forward in migration 0105 — whose header carries the whole argument, including what was reachable and why every existing writer passes the narrowed arm unchanged.

How a data subject is erased

credit_ledger, audit_log and run_events all have UPDATE revoked from every application role, so nothing running as botseon_app or botseon_privileged can rewrite an identifier on any of them once it is written. Erasure works by deleting the users row itself and letting each table's own owner — a referential-integrity trigger, not an application role, and therefore not subject to row-level security or the UPDATE revocation — pseudonymise what named it. credit_ledger (migration 0030) and, since migration 0032, audit_log and ten further application tables (bots, conversations, messages, runs — two columns — approvals, memories, attachments, policy_rules and memory_grants) carry on delete set null on their foreign key to users(id), so the identifier is nulled the instant the users row goes. run_events.actor_user_id carries no foreign key at all, deliberately — its hash chain (§2.10) must never be rewritten — so it is pseudonymised differently: the id it carries simply stops naming anyone once the users row is gone.

The visible consequence — an erased subject's bots, conversations and runs have a null owner and are therefore admin-visible only — is the first thing an operator notices, and it is why the owner-scoped RESTRICTIVE policies on bots and conversations route a null-owner row to their team-visibility arm rather than hiding it outright: an ownerless bot needs an administrator to re-home it.

Last verified against build c0f77aa.