Data Model

Full DDL: api/app/db/schema.sql. This document explains the why behind the shape.

Entity relationship overview

resellers ──< customer_resellers >── customers
    │           (cost_key, state,          │
    │            is_primary)               │
    │                                      │
    └──────< assets (properties) >─────────┘
              reseller_id, customer_id,
              cost_key, state
                 │
                 ├──< plans ──< enrollments
                 ├──< usage_records
                 └──< exchange_transactions >── counterparties

utilities ──< plans
    └──< rate_history

──< = one-to-many. Every arrow into assets points at the same table regardless of what kind of asset it is. customer_resellers is a many-to-many join carrying its own data (cost_key, state, is_primary) rather than a plain link table — see below.

The saved-query registry (saved_queries) is not in this diagram: it lives in a separate database entirely, with no foreign keys back into anything here. See "The query registry" below.

The tenancy model

A reseller is the tenant boundary. Every scoped query filters on reseller_id, so two resellers never see each other's customers, properties, or transactions.

A customer is not owned by a reseller. It attaches to many, one row per relationship in customer_resellers — the same end customer can be serviced by different resellers in different states without duplicating the customer record.

The cost key lives on the relationship, not on the customer and not on the reseller, because the key — and the state it applies in, and the effective dates — are facts about the relationship. It defaults to the reseller's UUID so it is never empty and always resolves back to a reseller row, and can be overridden with the reseller's own billing or settlement code.

Uniqueness is (customer_id, reseller_id, state) — state is part of the key on purpose, so the same customer/reseller pair can legitimately appear once for TX and again for NM.

At most one primary attachment per customer, enforced twice over: the service layer demotes the incumbent before promoting (in that order — a unique index cannot be deferred), and a partial unique index makes two primaries impossible even under concurrent writes:

CREATE UNIQUE INDEX uq_customer_one_primary_reseller
    ON customer_resellers (customer_id) WHERE is_primary;

Because it is partial, the many non-primary rows per customer are unconstrained — only the primaries collide.

A property is an asset carrying reseller_id, customer_id, state, and cost_key. The cost key is denormalized onto the asset so per-property attribution doesn't need a join on every read, but it is derived at write time from the customer's attachment rather than accepted from the client — a property can never be stamped with a key that doesn't correspond to a real relationship.

The query registry

Saved queries (saved_queries) live in a second, separate database — its own connection string (AXIOM_QUERY_DATABASE_URL), its own DeclarativeBase, no shared migration path with the tables above. This is a deliberate physical separation, not just a different schema:

A saved query is addressable by a short, quotable query_id (e.g. qry_7f3a9c2e4b81) assigned at instantiation, and is either stateful (the response body is frozen alongside the request) or stateless (only the request is stored, and replaying it hits live data).

usage_events lives in the same registry database, for the same reasons — one row per authorized, billable request, written by app/core/metering.py at the moment require_scope authorizes a call. See AUTH_ARCHITECTURE.md for the full metering model.

portal_users also lives here, though its fit is looser — it's platform login identity (for viewing docs/Swagger), not an ephemeral request-shaped artifact like the two tables above. Filed alongside them anyway rather than justifying a third database for a handful of rows; flagged honestly as a stretch of this database's original rationale, not claimed as the ideal long-term home.

Self-provisioning when only one physical Postgres instance exists

On a managed Postgres provider that only grants one free instance per account (Render's free tier, notably — see DEPLOYMENT.md), there's no way to get a second, fully separate server without paying for one. Two databases still exist — genuinely separate CREATE DATABASEs, no shared tables — they just share the same physical server as a cost-driven compromise, not the original two-instance design.

app/core/db_bootstrap.py automates the one manual step that setup would otherwise require: on AXIOM_ENVIRONMENT=local startup, if AXIOM_DATABASE_URL and AXIOM_QUERY_DATABASE_URL resolve to the same host, the app connects with its already-working inventory credentials and issues CREATE DATABASE for the registry database itself, using the inventory user's CREATEDB privilege (granted by default on Render and most managed Postgres providers). Verified locally: dropping the registry database and booting from cold correctly recreates it end to end (portal login, session check, everything downstream all pass on the same boot); a second boot with the database already present is a harmless no-op (DuplicateDatabaseError caught, nothing logged as an error). If the two URLs point at different hosts — the cleaner setup, once a second real instance exists — this function does nothing.

This isn't just a local-sandbox mechanism: it's exactly what runs the live deployment at https://axiom-exchange-api.onrender.com, which really does use a single free-tier Postgres instance for both databases — see "Live deployment verification" in docs/TESTING.md for the real-world confirmation that the first boot against Render's actual server succeeded end to end.

Why one assets table instead of one table per asset type

A well pad, a solar farm, a service territory, a pipeline segment, and a customer premise have almost nothing in common structurally — different attributes, different owners, different lifecycles. Modeling them as separate tables (well_pads, solar_farms, premises, ...) means every new asset type is a migration, and anything that needs to query "assets near this point" or "assets in this polygon" regardless of type has to union across N tables.

Instead:

The trade-off: you lose column-level type constraints per asset type (nothing stops someone from putting capacity_mw on a well_pad). If a particular asset type grows enough structured, always-present fields that this becomes a real problem, that's the signal to split it into its own table with a foreign key back to a slim assets row for the shared geometry/metadata — the generic table is a starting point, not a permanent constraint.

Why PostgreSQL + PostGIS + JSONB instead of a separate NoSQL store

The original ask was "PostgreSQL or Cosmos/Aurora type of NoSQL database due [to] the type of structured and unstructured data." The attributes JSONB pattern above is the unstructured-data answer — Postgres's JSONB type gives schema-per-document flexibility (index individual keys, query with @>/?/path operators, no migration to add a field) in the same table as strongly-typed structured columns and foreign keys. That avoids running two databases (one relational, one document) and keeping them consistent, while still being directly portable to a managed NoSQL-adjacent-but-relational service:

Table reference

Table Database Purpose Geometry Tenancy columns
resellers inventory Tenant / account holder, with operating_states (is the tenant)
customers inventory End customer, not owned by one reseller via customer_resellers
customer_resellers inventory The cost-keyed attachment cost_key, state, is_primary
assets inventory Every spatial thing / property, any type, any shape GEOMETRY (any type) reseller_id, customer_id, cost_key, state
utilities inventory Utility companies / balancing authorities MULTIPOLYGON (territory)
plans inventory Energy plans/products reseller_id (NULL = platform-wide)
rate_history inventory Historical area rate snapshots
usage_records inventory Interval/period usage per premise asset via asset
enrollments inventory Customer sign-ups against a plan reseller_id, customer_id, cost_key
counterparties inventory Exchange trading counterparties reseller_id
exchange_transactions inventory Nominations/trades/settlements reseller_id, customer_id, cost_key
saved_queries query registry (separate DB) Instantiated request + optional frozen response reseller_id (opaque, no FK)
usage_events query registry (separate DB) One row per authorized, billable request, for metering reseller_id, organization_id (opaque, no FK)
portal_users query registry (separate DB) Portal/Swagger login identity (username, bcrypt hash, Twilio 2FA fields) — a different concern from API scope tokens; see AUTH_ARCHITECTURE.md none — this is platform identity, not tenant data

Indexing strategy