:::info Executable tutorial
This page is generated from a tested Jupyter notebook. Open it in Google Colab or view the source notebook.
:::
Manage pricing configuration with the CLI
The Bursar command-line interface (CLI) applies schema migrations, manages tenants, validates candidate configuration, publishes immutable revisions, and activates a prior revision during rollback. This tutorial models those operator actions without exposing credentials in command arguments.
Learning objectives
After completing this tutorial, you can:
- Provision and inspect a tenant from the CLI
- Validate configuration in continuous integration
- Publish, compare, export, and activate revisions
- Define a repeatable publication and rollback sequence
Prerequisites
Install the SDK with the Postgres extra, which also provides the bursar entry point:
pip install "bursar[postgres]"
Use a dedicated migration-owner connection to install the schema:
export BURSAR_MIGRATION_DATABASE_URL="postgresql://bursar_migrator@host:5432/bursar"
bursar migrate
# -> Migrations applied successfully.
The CLI reads credentials only from explicit environment variables; it does not auto-load .env. After migration, provision separate SET-only bursar_operator and bursar_client login members as shown in the CLI guide. Supply those connections independently:
export BURSAR_OPERATOR_DATABASE_URL="postgresql://bursar_ops@host:5432/bursar"
export DATABASE_URL="postgresql://bursar_app@host:5432/bursar"
export BURSAR_TENANT_ID="00000000-0000-0000-0000-000000000001"
export BURSAR_PROVIDER_ENVIRONMENT="test"
Migrations are checksummed and idempotent: the runner records the filename and SHA-256 of every applied script, skips scripts that already ran, and refuses to proceed on a checksum mismatch. Re-running bursar migrate in a pipeline is always safe. Never give the application migration-owner, superuser, BYPASSRLS, or Supabase service_role credentials. Run application-owned SQL through the application's migration tool with its own ledger and transaction boundary.
Provisioning the tenant
A tenant must exist before any config or store operation. tenant create provisions one and prints the tenant UUID (--id pins a specific one; --display-name is optional):
bursar tenant create acme --display-name "Acme Production"
# -> 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c
tenant bootstrap does two steps in one: provision the tenant and publish its first config. It validates the file before provisioning, so a malformed document can never leave a tenant behind that cannot start:
bursar tenant bootstrap promo pricing.prod.yaml --label "initial: standard rate card"
# -> Tenant 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c bootstrapped successfully (config applied).
Tenant lifecycle is explicit - activate, suspend, or close an account. A suspended tenant stops admitting usage immediately:
bursar tenant status 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c suspended
# -> 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c
Shipping a pricing change
The core loop is config set. It reads a JSON or YAML document (or - for stdin), validates it against the BursarConfig model - types, cross-references, and every pricing expression - and publishes it as a new immutable version. Previous versions are never overwritten; the audit trail is append-only.
bursar config set pricing.prod.yaml --label "change-42: pro quota 500k -> 600k"
# -> Bursar config set successfully.
Setting a document identical to the active version is a no-op, so pipelines are safe to re-run:
bursar config set pricing.prod.yaml --label "change-42 (retry)"
# -> No changes - config is identical to the active version.
Inspect what is live:
bursar config get
config get prints the canonical JSON of the active revision: the revision id, the version number, and the full config document (money as decimal strings, defaults filled in). Truncated for readability:
{
"id": "019fbb6d-...",
"config": {
"version": 1,
"catalog": {"default_plan": "free"},
"pricing": {"operations": {"completion": {...}, "execution": {...}}, "rate_cards": {"standard": {...}}},
"credits": {"buckets": {"promotional": {"priority": 1}, "purchased": {"priority": 10}}, "default_bucket": "purchased"},
"plans": {"free": {...}, "pro": {...}},
"commerce": {"providers": {"stripe": {"type": "stripe"}}, "offers": {...}}
},
"version": 3
}
Version history, newest first, the active version starred:
bursar config list
# * v3 (id=019fbb6d...) change-42 2026-08-01T10:30:00
# v2 (id=019fbb6e...) change-37 2026-07-28T09:12:00
# v1 (id=019fbb6f...) initial 2026-07-25T08:00:00
Each row is one immutable revision: active marker, version, truncated revision id, label, timestamp.
Rolling back
Every revision stays available, so the catalog rollback path is a single command with no schema migration or cache flush:
# Something is wrong with change-42. Activate the previous revision.
bursar config activate 2
# -> Pricing v2 activated.
Activation deactivates all other revisions and marks the requested one active inside a single transaction. Go back (rollback) or forward (restore) at will; activation never creates a new version, so the history stays clean.
tenant status is the complementary control: it suspends the whole tenant when an incident needs to stop the meter outright:
bursar tenant status 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c suspended
# -> 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c
# (set it back to 'active' once the new pricing has been verified)
The CI gate: config validate
config validate parses and validates a file without touching the database - no DATABASE_URL, tenant, provider environment, or store. That makes it the natural first step of every pipeline: catch a bad document before it is ever published.
bursar config validate pricing.new.yaml
# -> Bursar config is valid.
Human-readable errors go to stderr and the exit code is 1. For CI, ask for the machine-readable form:
bursar config validate pricing.new.yaml --json
# {
# "valid": true,
# "errors": []
# }
On an invalid document, --json reports the structured validator errors - the same shapes ConfigError.errors() produces in Python:
{
"valid": false,
"errors": [
{
"type": "decimal_string",
"loc": ["pricing", "rate_cards", "standard", "operations", "completion", "rules", 0, "charge", "sum", "components", 0, "per_unit", "rate"],
"msg": "must be a base-10 decimal string",
"input": 0.0025
}
]
}
For editor autocompletion and linting, the CLI also emits the full JSON Schema:
bursar config schema > pricing.schema.json
(Notebook 15 covers the schema end to end.)
Comparing and exporting versions
Before activating a rollback, see exactly what changed between two revisions. config diff produces a unified diff over the canonical JSON of each revision:
bursar config diff 2 3
# --- v2
# +++ v3
# @@ ... @@
# - "display_name": "Pro",
# + "display_name": "Pro Tier",
config export dumps one revision's document as JSON - the starting point for the safe edit-publish loop:
# 1. Export the live revision
bursar config export 3 > current.json
# 2. Edit it in your editor (or a script)
# 3. Validate the edit - no database required
bursar config validate current.json
# 4. Publish the new revision with an audit label
bursar config set current.json --label "change-43: sonnet-4.1 rates"
Export to edit to validate to set is the recommended way to make surgical changes: you always start from a document that was valid before you touched it.
The CLI in action, from this notebook
Everything above is shell work, and this notebook cannot run those commands against your database. What it can do is prove the one property that makes the whole workflow safe: config validate is pure. It parses the document and validates it in memory - no database, no environment variables, no tenant.
The cell below serializes the shared base_config() to a temporary JSON file, then invokes python -m bursar config validate <file>, the same code path used by the installed CLI. It also requests the --json continuous-integration form and runs config list without DATABASE_URL to verify that store commands fail with a clear error.
Expected output: exit code: 0 with stdout: 'Bursar config is valid.'; the --json run prints {"valid": true, "errors": []}; and the store command exits 1 with DATABASE_URL is required on stderr.
import json
import os
import subprocess
import sys
import tempfile
from shared import base_config
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump(base_config(), handle)
pricing_file = handle.name
def run_cli(*args, env=None):
merged = dict(os.environ) if env is None else dict(env)
return subprocess.run(
[sys.executable, "-m", "bursar", *args],
capture_output=True,
text=True,
env=merged,
)
try:
ok = run_cli("config", "validate", pricing_file)
print(f"exit code: {ok.returncode}")
print(f"stdout: {ok.stdout.strip()!r}")
json_gate = run_cli("config", "validate", pricing_file, "--json")
print(f"exit code (--json): {json_gate.returncode}")
print(json_gate.stdout.strip())
no_db = {
k: v for k, v in os.environ.items()
if k not in ("DATABASE_URL", "BURSAR_TENANT_ID", "BURSAR_PROVIDER_ENVIRONMENT")
}
denied = run_cli("config", "list", env=no_db)
print(f"exit code without DATABASE_URL: {denied.returncode}")
print(f"stderr: {denied.stderr.strip()}")
finally:
os.unlink(pricing_file)
The catalog publication workflow
A complete, numbered host-controlled workflow for pricing changes:
# 0. One-time setup (a provisioning job, not the pipeline):
export BURSAR_MIGRATION_DATABASE_URL="<migration-owner-url>"
bursar migrate
# Provision separate bursar_ops and bursar_app logins as documented.
export BURSAR_OPERATOR_DATABASE_URL="<operator-url>"
export DATABASE_URL="<application-url>"
export BURSAR_PROVIDER_ENVIRONMENT="test" # use live with production provider credentials
bursar tenant create acme --display-name "Acme Production" # retain the printed UUID
export BURSAR_TENANT_ID="<printed-uuid>"
# 1. Validate the candidate - pure, runs anywhere, fails fast
bursar config validate pricing.new.yaml --json
# 2. Publish as a new immutable revision, labeled with the change id
bursar config set pricing.new.yaml --label "$(git rev-parse --short HEAD): pro quota bump"
# 3. Confirm what is live
bursar config get
# 4. Smoke-test the new pricing (a priced request against the test tenant)
# 5. If anything misbehaves, roll back in one command
bursar config activate <previous-version>
The rules of the road:
- Everything is versioned - publishing never overwrites;
config listis the audit trail. - Labels carry context - git hashes and change ids turn the history into a readable changelog.
- Validate before you set -
config validateneeds no database, so it gates every pipeline. - Rollback is activation - one atomic catalog command.
- Sensitive values stay out of argv - migration, operator, application, tenant, and provider-environment values are read from the environment.
- The host owns execution - Bursar exposes these commands but does not prescribe CI/CD, process supervision, or cloud deployment.
That is the operator loop: validate, publish, verify, roll back - all versioned, all auditable.