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, seedocs/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. Seedocs/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. Seedocs/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
| role | what it can do | what it holds |
|---|---|---|
botseon_app | reads and writes tenant rows, filtered by row-level security keyed on app.org_id/app.actor_user_id | nologin — nothing connects as it; the runtime set local roles into it |
botseon_privileged | cross-organisation reads and writes, gated by _priv policies and an audited reason | nologin, same as above |
botseon_runtime | nothing 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_migrator | DDL: creates tables, roles, policies, grants | nologin, 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'srecordUsagewritesusage_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'sdrawForUsagedraws against the organisation's credit balance, keyed on thecredentialKind@botseon/billing'scredentialKindForcomputed for the call (platformdraws;byokandlocal— 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 saveCheckpoint — recordUsage 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.generateto 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.