Verification Log
This scaffold was not just written — it was run. Every endpoint below was exercised against a real PostgreSQL 16 + PostGIS 3 instance and a live uvicorn process, not unit-tested in isolation. Five real bugs were found and fixed in the process; all are documented here so the fix history isn't lost.
Environment
- PostgreSQL 16 + PostGIS 3.4, local instance
- Python 3.12, dependencies from
api/requirements.txtin a clean venv - App run directly with
uvicorn app.main:app(equivalent to the Dockerfile's gunicorn setup, minus multi-worker concurrency)
What was verified end-to-end
| Check | Result |
|---|---|
| All Python files byte-compile | ✅ |
| FastAPI app imports, all 29 routes register | ✅ (after adding email-validator, missing from initial requirements.txt) |
Table creation (CREATE EXTENSION postgis + create_all) against live DB |
✅ |
| Seed script inserts a utility territory polygon, a solar-farm polygon, a premise point, a plan, a rate history row | ✅ |
GET /healthz, GET /readyz |
✅ |
GET /assets?asset_type=... returns real seeded rows |
✅ |
GET /assets/{id}/geometry — polygon round-trips through PostGIS back to GeoJSON with exact coordinates preserved |
✅ |
GET /assets/{id}/geometry?format=esri — same polygon as Esri JSON rings |
✅ |
POST /geo/query — polygon intersection returns correct mixed-geometry results (both a Polygon and a Point asset in one FeatureCollection) |
✅ |
GET /geo/bbox — viewport query returns correct feature count |
✅ |
POST /geo/query/radius — geodesic proximity search |
✅ (after fix — see below) |
GET /utilities/lookup — point-in-polygon utility match |
✅ (after fix — see below) |
POST /assets — create a new polygon asset (well pad) |
✅ |
PUT /assets/{id} — partial update (status change) |
✅ |
DELETE /assets/{id} |
✅ (204, then confirmed gone) |
GET /plans, GET /rates/{utility_id}/history |
✅ |
POST /enrollments, GET /enrollments/{id} |
✅ |
POST /exchange/transactions (tied to a physical asset via asset_id), GET /exchange/transactions?asset_id=... |
✅ |
GET /usage/{premise_id}, GET /usage/{premise_id}/forecast |
✅ |
POST /auth/token — JWT issuance |
✅ |
Unauthenticated request → 401 with correct error body |
✅ |
GET /openapi.json — valid spec, 22 paths |
✅ |
Reseller multi-tenancy verification
Run as a second pass after the tenancy work, against the same live PostgreSQL/PostGIS setup:
| Check | Result |
|---|---|
| One customer attached to 2 resellers across 3 states, each attachment cost-keyed | Pass |
| Cost key defaults to the reseller UUID; explicit override honored | Pass |
| Reseller-scoped JWT sees only its own properties (2 of 3) | Pass |
| Reseller 1 reading Reseller 2's property directly | Pass (403) |
| Shared customer: each reseller sees only its own attachment rows | Pass |
Spatial /geo/bbox respects tenant scope across a 3-state box |
Pass |
/geo/customers/search returns customers + properties + cost keys in an area |
Pass |
/geo/utilities/status returns per-utility status and affected property counts |
Pass |
Attaching in a state outside the reseller's operating_states |
Pass (422) |
Duplicate (customer, reseller, state) attachment |
Pass (409) |
/cost-keys rollup maps each key to its reseller, customers, properties, states |
Pass |
Partial unique index rejects a second primary via direct SQL UPDATE |
Pass (unique violation) |
| Service-layer promote still succeeds through that index (demote-then-promote) | Pass (exactly 1 primary after) |
Canonical schema.sql applies clean from scratch, index present |
Pass |
GeoJSON and Esri features carry reseller_id / customer_id / cost_key / state |
Pass (after fix — see below) |
Bugs found and fixed during verification
1. Missing email-validator dependency
Pydantic's EmailStr type (used on EnrollmentCreate.customer_email)
requires the optional email-validator package, which was not pinned in
requirements.txt. The app failed to import at all until this was added.
Fix: requirements.txt now pins pydantic[email]==2.9.2 in addition
to bare pydantic.
2. Raw WKT strings passed to PostGIS functions without a geometry cast
GET /utilities/lookup and POST /geo/query/radius originally built a
point as an f-string (f"SRID=4326;POINT({lon} {lat})") and passed it
straight into ST_Contains(...) / ST_DWithin(...). PostgreSQL has no
ST_Contains(geometry, character varying) overload, so both endpoints
returned a 500 with UndefinedFunctionError: function st_contains(geometry, character varying) does not exist.
Fix: both endpoints now build a real shapely.geometry.Point and
convert it with geoalchemy2.shape.from_shape(point, srid=4326), the
same pattern already used for asset geometry writes — this produces a
properly-typed WKBElement that SQLAlchemy binds as an actual
geometry parameter instead of text.
3. geoalchemy2.functions.ST_Geography doesn't exist as a callable
The radius query's geography cast (geofunc.ST_Geography(...)) raised
AttributeError: module 'geoalchemy2.functions' has no attribute 'ST_Geography' — GeoAlchemy2 doesn't pre-register that name as a
function proxy in this version.
Fix: switched to SQLAlchemy's generic cast(Asset.geom, Geography)
(from geoalchemy2.types.Geography), which compiles to the same
::geography cast PostGIS expects without depending on a specific
ST_* function proxy being registered.
4. Cost key silently mis-attributed across states
A property created in a state the customer had no attachment for was accepted and stamped with an unrelated state's cost key — a property in Kansas inherited the Oklahoma key. That is exactly the mis-billing the cost key exists to prevent, and it failed silently rather than loudly.
Fix: cost_key_for() no longer falls back to an arbitrary
attachment. A state-specific attachment wins, then a stateless catch-all;
if neither exists the request fails with 422 naming the states that are
covered, so the caller creates the right attachment instead of getting
wrong data.
5. GeoJSON/Esri output omitted tenancy attribution
asset_to_feature() and asset_to_esri_feature() were never updated
when the tenancy columns were added, so every map feature came back
without reseller_id, customer_id, cost_key, or state. An ArcGIS
layer therefore could not style or filter by reseller, and the property
could not be attributed to a cost key without a second round trip —
which defeats the point of the attribution work. Caught while wiring the
front-end to real exported output rather than to hand-written fixtures.
Fix: both serializers now emit the four tenancy fields alongside the existing properties.
v0.1.0 — query registry verification
| Check | Result |
|---|---|
GET / reports version 0.1.0 |
Pass |
saved_queries exists in the registry DB and is absent from the inventory DB |
Pass (0 matches in inventory pg_tables) |
Stateless query instantiation returns a query_id |
Pass |
Stateful query freezes a result and reports result_count |
Pass |
Retrieval by query_id returns the frozen snapshot unchanged |
Pass |
Replay re-executes against live data and increments replay_count |
Pass |
| Replay of a stateful query leaves the snapshot intact | Pass |
| Non-allowlisted endpoint rejected | Pass (422) |
response_snapshot without is_stateful rejected |
Pass (422) |
Unknown query_id |
Pass (404) |
| Partial unique index rejects a second primary via direct SQL | Pass (unique violation) |
| Service-layer promote succeeds through that index | Pass (exactly 1 primary after) |
GET /contact returns organization contact, no individual details |
Pass |
Heading-anchor slugs resolve under marked, real build, all 7 pages |
Pass (16/16 in-page links resolve) |
| Docs nav template actually renders the isotope + wordmark lockup | Pass (after fix — see below) |
Isotope vectorization
The OEPC isotope was supplied only as a PNG. Traced to SVG with potrace,
one pass per color layer (cyan bars, orange bars, gradient V, white
waveform), splitting bars from the V on the measured slant boundary
(x = 538 + 0.1876y) rather than by color — the V's mid-gradient tone
matches the cyan bars and misclassifies under naive color splitting.
Fidelity was verified by rendering the assembled SVG back to a bitmap and diffing it against the source PNG pixel-for-pixel, not by visual inspection alone:
| Check | Result |
|---|---|
| Mean channel difference vs. source PNG | 3.52 / 255 |
| Pixels differing by more than 60/255 | 0.34% |
| Gradient orientation matches source (top-light to bottom-dark on the V) | Pass (after fix — first pass was inverted, see below) |
The first render scored 11.05 mean difference because potrace's group
transform flips the Y axis, which silently inverts objectBoundingBox
gradients — the V rendered dark-to-light instead of light-to-dark.
y1/y2 in the shipped SVG are written in that flipped coordinate
space, with a comment, so the fix isn't later "corrected" back to the
visually-intuitive-but-wrong values.
Auth architecture verification (scopes, roles, environments, metering)
Run against a live server after wiring Security(require_scope(...))
onto every route (34 operations across 9 routers):
| Check | Result |
|---|---|
App boots, /openapi.json valid, 38 paths |
Pass |
Every operation except health/auth/contact/root has a populated security block |
Pass (44/44 route+method combinations) |
security block's scope matches what the route's dependency actually enforces (not left to FastAPI's default, which doesn't auto-populate for plain-callable dependencies — see bug below) |
Pass, after fix |
POST /auth/token with role: developer resolves the role's default scope bundle |
Pass |
Explicit scopes in the token request overrides the role's default bundle |
Pass |
customer:read token can list/read tenancy resources |
Pass (200) |
customer:read-only token attempting POST /resellers (needs admin) |
Pass (403, correct missing-scope message) |
admin + environment=production token creating a reseller |
Pass (201) |
trading_partner role + environment=sandbox attempting trading:execute |
Pass (403 — privileged scope requires production, exact message verified) |
trading_partner role + environment=production attempting the same call |
Pass (scope check clears; request proceeds to business logic — hit an unrelated pre-existing FK constraint on synthetic test data, confirming the request reached the handler rather than being blocked by auth) |
customer role (no trading:read) reading /exchange/transactions |
Pass (403) |
developer role (no enrollment:write) submitting an enrollment |
Pass (403) |
X-Axiom-Gateway-Identity + valid secret bypasses scope checks entirely |
Pass (unaffected by the scope refactor) |
sk_test_... API key resolves to environment=sandbox; role bundles applied when no per-key override configured |
Pass |
Role bundles match the architecture brief's own worked example (partner: customer:read yes, customer:write no) |
Pass — confirmed by the customer:write-on-/queries 403 below being a deliberate default, not a bug |
POST /queries requires customer:write; neither customer nor partner role has it by default |
Pass (403 for both — matches least-privilege intent, not a gap) |
usage_events table created in the registry database (not inventory) |
Pass |
Billable scope (tariffs:read, serviceability:read) writes a usage_events row with the correct billing_code |
Pass — GET /plans ×2 → 2 rows, BASIC_CALL; GET /utilities/lookup → 1 row, ADDRESS_SERVICEABILITY |
Non-billable scope (catalog:read) does NOT write a usage event |
Pass — GET /utilities produced zero new rows |
Bug found: OpenAPI didn't reflect per-operation scopes despite correct runtime enforcement
Security(require_scope(Scope.X)) enforced the right scope at request
time from the moment it was wired in, but every operation's OpenAPI
security block came back empty — FastAPI only auto-populates that
block for its own fastapi.security.* scheme classes (e.g.
OAuth2AuthorizationCodeBearer), not for an arbitrary callable
dependency like require_scope's closure. Left as-is, Swagger UI's
Authorize flow would have no way to know which scope a given "Try it"
call needed, even though the server-side check was already correct —
exactly the kind of docs/code mismatch this whole exercise was meant to
prevent.
Fix: tagged the scope onto the dependency function itself
(_checker.axiom_scope = scope) and, in main.py's custom_openapi(),
walked every route's dependency tree looking for that tag, writing the
real per-operation security block from it. Verified: POST /exchange/transactions → [{"OAuth2": ["trading:execute"]}, ...],
GET /plans → [{"OAuth2": ["tariffs:read"]}, ...], and so on for all
44 route/method combinations — matching the enforcement exactly because
it's now generated from the same source, not maintained in parallel.
Portal login gate verification (Swagger/docs authentication)
Run against a live server after gating /docs, /redoc, /openapi.json,
and adding POST /portal/login + the protected doc-content endpoints:
| Check | Result |
|---|---|
No session: GET /docs returns the self-contained login page, not Swagger UI |
Pass |
No session: GET /openapi.json |
Pass (401) |
| Wrong password | Pass (401, generic message — no username enumeration) |
Correct password (dev seed user axiom-admin) sets a working httpOnly session cookie |
Pass |
With session: GET /openapi.json returns the real spec |
Pass (44 operations) |
With session: GET /docs returns real Swagger UI |
Pass |
No session: GET /portal/docs/deployment and /portal/docs/auth-architecture |
Pass (401 each) |
| With session: both return real markdown | Pass (8,103 and 8,391 characters respectively) |
GET /portal/session reflects the logged-in username |
Pass |
POST /portal/logout clears the cookie; every gated route re-blocks |
Pass |
| Cross-origin CORS preflight from the deployed Vercel docs origin | Pass — access-control-allow-credentials: true with the correct origin echoed, confirming a separately-hosted portal.html can complete this login against a real API instance |
| Twilio Verify client with no credentials configured | Pass — is_configured is False; send_verification() raises a typed TwilioNotConfiguredError rather than silently no-op'ing; the login route checks this and skips the OTP step entirely, so an account can never be locked out by a 2FA step with no way to deliver a code |
Static ID cross-reference on portal.html: every getElementById target exists in the HTML, no duplicate ids |
Pass |
Real node build.mjs run confirms deployment.html/auth-architecture.html are no longer generated, and every remaining page's links to them resolve into /portal.html#docs |
Pass |
Bug: passlib's bcrypt backend detection crashed application startup
passlib 1.7.4's version-detection code for its bcrypt backend
references bcrypt.__about__, which the installed bcrypt 5.0.0 no
longer has — a known compatibility break between the two packages. This
surfaced as a ValueError deep in passlib's internal self-test
(detect_wrap_bug) on the very first password hash, taking the whole
app down at startup (the dev-user seed step runs during the FastAPI
lifespan).
Fix: bypassed passlib's CryptContext entirely and call bcrypt
directly (bcrypt.hashpw/bcrypt.checkpw) in core/portal_auth.py,
rather than pinning to an older, less-maintained bcrypt release to
satisfy passlib's shim.
Bug: Dockerfile never copied docs/ into the image
GET /portal/docs/{id} reads docs/DEPLOYMENT.md and
docs/AUTH_ARCHITECTURE.md from disk, but the Dockerfile's COPY app ./app was the only content copy — the container would have had no
docs/ directory at all, 404ing the one endpoint that most needed it
to work in a real deployment. Caught by tracing the actual Docker build
steps, not by local testing (which runs directly from the repo checkout
and never exposed the gap).
Fix: added COPY docs ./docs to the Dockerfile, and
portal_auth.py's DOCS_DIR resolution checks both the containerized
layout (/app/docs) and the local-dev repo layout (docs/ beside
api/) and uses whichever actually exists.
Bug: #docs deep-link handler fired before its own click listener existed
web/portal.html's hash-based deep link (if (location.hash === "#docs") { ...click()... }) was placed at top-level script scope,
executed top-to-bottom as the script parses — but the .ptab click
listeners it depends on are registered further down the same file.
Calling .click() on an element with no listener attached yet is a
silent no-op, so a direct link to /portal.html#docs would load the
page but never actually switch to the Docs tab.
No browser tool is available in this sandbox to catch this by clicking
through the page, so it was found by static trace: extracting every
getElementById target and cross-referencing it against the HTML (no
missing IDs, no duplicates), then manually walking script execution
order top-to-bottom for anything that depends on a not-yet-registered
listener. Fix: moved the hash check to execute after every listener
in the file is bound, with a comment explaining why the original
placement was silently broken.
Developer portal verification
| Check | Result |
|---|---|
GET /usage/me aggregates real cross-session usage by billing_code for the calling subject |
Pass — 7 real calls (plans×4, utilities/lookup×2, customers×1) correctly rolled up into BASIC_CALL (5, $0.005) and ADDRESS_SERVICEABILITY (2, $0.01) |
/usage/me registered ahead of /usage/{premise_asset_id} so Starlette doesn't try to parse "me" as a UUID |
Pass — /usage/{valid-uuid} still correctly 403s on missing meter:read, unshadowed |
web/portal.html embedded JS is syntactically valid |
Pass (node --check on the extracted script) |
build.mjs produces portal.html and links it from every docs page's nav |
Pass — verified with a real node build.mjs run |
| CORS actually permits a browser-hosted portal to call a locally-run API cross-origin | Pass — real OPTIONS preflight against Origin: https://axiom-exchange-docs-veritris.vercel.app returned access-control-allow-origin echoing that origin |
Catalog card data matches BILLING_CODE/ROLE_SCOPES in app/core/scopes.py exactly, including which scopes have no endpoint yet |
Pass (manually cross-checked; the reserved cards are the same reserved rows as API_TIERS.md) |
What "live mode" actually means: the portal has no fake success paths.
Signing in with an API base URL set makes a real POST /auth/token
call; the Explorer's "Run request" makes a real authenticated call to
whatever base URL is configured, and the Dashboard's usage numbers come
from a real GET /usage/me. Leaving the API base URL blank switches to
a clearly-labeled demo mode (matching the banner convention already
used in explorer.html) rather than silently faking success — there is
no code path where the portal claims a request succeeded against a real
server when it didn't reach one.
What the portal does NOT do, honestly: no real Google/Microsoft sign-in for API tokens specifically (the role/environment prompt under "Get API token" stands in for it, labeled as such in-page — this is a separate concern from the portal login, which now uses a real username/password against the backend, not a stand-in). No billing/ payment UI, no marketplace, no webhooks UI. The API-token session still lives in an in-memory JS variable by design (a real portal needs a proper token-storage story for that too — an httpOnly cookie via a backend-for-frontend, not a raw client-side JWT); the portal login session, by contrast, is a real httpOnly cookie set by the server, not client-visible JS state — see "Portal login gate verification" above. This is the P0 "developer foundation" slice — catalog, a real portal login, a separate API-token flow, dashboard, explorer, gated docs — not the full 65-section spec.
Live deployment verification (Render)
Deployed the API to a real, publicly reachable instance:
https://axiom-exchange-api.onrender.com. Two Render Postgres
databases were originally planned; the free tier only permits one
instance per account, so AXIOM_DATABASE_URL and
AXIOM_QUERY_DATABASE_URL point at the same server with different
database names, and app/core/db_bootstrap.py (see DATA_MODEL.md)
self-provisions the second database on first boot — this section is
the real-world proof that mechanism actually works, not just the local
test recorded above.
| Check | Result |
|---|---|
| First build attempt | Failed — Render's default Python 3.14 has no prebuilt wheel for pydantic-core, and pip's source-build fallback (Rust/maturin) failed because Render's build environment disallows writes to cargo's cache directory |
| Fix | Pinned PYTHON_VERSION=3.12.7 (matching every local verification in this log) via a Render env var; triggered a fresh build |
| Second build attempt | Passed. Application startup complete in the runtime logs — meaning inventory schema creation, registry self-provisioning, registry schema creation, and the admin-user seed all succeeded in sequence against the real server, since a failure at any step would have crash-looped the service rather than reaching that log line |
GET / on the live URL |
Pass — real {"service":...,"version":"0.1.0",...} |
GET /docs with no session |
Pass — real login page rendered, confirmed via a connected browser, not curl |
| Login with the real (non-default) admin password | Pass — redirected to actual Swagger UI, correct title/version/description, scope lock icons visible exactly as designed |
Bug: a stale local git branch silently absorbed every commit
All work in this session had been happening on a local branch named
feat/reseller-multi-tenancy, left over from early in the project,
while a separate local branch literally named main sat frozen at the
very first commit. Every git push origin main was pushing that
frozen branch — genuinely succeeding, genuinely reaching GitHub, and
genuinely useless, since it never contained any of the session's work.
git push's own "Everything up-to-date" message reported this
confidently and incorrectly, because the local branch really was
up to date with what had actually been pushed.
Caught by: independently re-fetching origin/main and diffing its
file tree against what the code was supposed to contain (grep-ing for
files like portal_auth.py that should exist post-implementation),
rather than trusting git push's own success/no-op reporting.
Fix: git push origin HEAD:main to push the actual current branch
regardless of its local name; then reset local main to match and
deleted the stale branch so this can't recur.
Bug: Render's GitHub App was never actually installed on this account
create_web_service rejected a syntactically valid, correctly-owned
repository URL with "invalid or unfetchable" — not a permissions error,
just a flat rejection. The account had something configured under
Render's Account Settings → Account Security → Git Deployment
Credentials, which looked plausible but turned out to be a different
mechanism from the GitHub App installation that actually powers
repository access for service creation. Confirmed by checking Account
Settings → Connections, which listed no GitHub connection at all.
Fix: completed the real flow — Render dashboard → New → Web Service
→ GitHub → Connect account, which redirects to
github.com/apps/render/installations/new, explicitly granting access
to the one repository needed. Verified afterward by revisiting the
repo-picker page and seeing veritris-energy/etrm listed with a
Connect button.
What was not covered
- Load testing / concurrency behavior under the multi-replica nginx setup (verified nginx config syntactically; did not spin up the full docker-compose stack in this sandbox — no Docker daemon available here, only a bare Python/Postgres environment).
- Alembic migration path (schema currently relies on
create_all, as documented indocs/DEPLOYMENT.md). - Automated test suite — this was manual, one-off verification via
curl. Before real usage, port these same checks intopytest+httpx.AsyncClientso they run on every change instead of once. - Query-registry retention/GC.
expires_atis honored on read (410) but nothing prunes expired rows yet — add a scheduled cleanup before the registry runs unattended. - The AXIOM wordmark lockup (VERITRIS / ENERGY / AXIOM) matches what veritrisaxiom.energy ships, confirmed by fetching the live site — but the exact typeface could not be confirmed (a separate fetch of veritris.energy directly was blocked by bot detection at one point in this session), so the lockup uses the system font stack, not necessarily the brand's actual typeface.
- Real Google/Microsoft OIDC federation for API tokens.
OIDC_*settings exist and populate the OpenAPI security scheme, but nothing calls them —POST /auth/token(this service's own issuer) remains the only way to get a bearer token. Standing up real federation needs actual OAuth app registrations with Google/Microsoft and a deployed, publicly reachable OIDC endpoint, neither of which exist in this environment. (The separate portal login, for viewing docs/Swagger, is now real standard username/password — see above — deliberately not routed through a third-party identity provider, per instruction.) - Tiered
/openapi/<tier>.jsondocument splitting./openapi.jsonis now gated behind a real portal login (see above), but that's a single coarse gate — "logged in or not" — not the finer public/customer/ partner/trading splitAPI_TIERS.mddescribes. See that document for why the per-operation scope data the split needs already exists, and what's still missing (the actual filtered-document routes, each independently gated by role rather than by one shared login). - A real API-key table with per-organization ownership, expiration,
rotation, and revocation.
AXIOM_API_KEYSis still a static list — functionally correct for sandbox/dev, not a production credential store. - Anything consuming
usage_eventsfor actual billing. The events are recorded correctly (verified above); nothing aggregates or invoices them yet. - Portal login hardening: no rate limiting or lockout on repeated failed
password attempts, no password-reset flow. The dev seed user defaults
to
axiom-admin/change-me-axiom-dev— on the live Render deployment this was overridden viaAXIOM_PORTAL_ADMIN_USERNAME/AXIOM_PORTAL_ADMIN_PASSWORDto a real generated credential before the service was ever reachable, but the underlying gate (AXIOM_ENVIRONMENT=local) is a bootstrap convenience, not a security boundary — a future deploy that forgets to set those two env vars would silently ship the known default.