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

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.

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