Skip to main content
Version: 2.x

:::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 list is the audit trail.
  • Labels carry context - git hashes and change ids turn the history into a readable changelog.
  • Validate before you set - config validate needs 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.