Deployment

Customer Management Guide

Version: 1.2.0 | Updated: 2026-06-12 | Status: Active (Cloud Run + Neon + Unkey)

Version: 1.2.0 | Updated: 2026-06-12 | Status: Active (Cloud Run + Neon + Unkey)

This guide covers AEGIS customer management for platform operators: creating customers, querying usage, and understanding the Cloud Run + Neon Postgres + Unkey integration.


1. Overview

AEGIS enforces per-tier service limits in production. Each customer is issued an Unkey-managed API key whose per-minute rate limit and monthly-evaluation quota are derived from the customer's tier (see src/aegis_governance/tiers.py). When a customer exceeds their rate limit or monthly quota, the API rejects the request with HTTP 429 (with RateLimit-* headers; a Retry-After header is included for short-window rate-limit throttles but omitted for monthly-quota exhaustion, which resets at the billing cycle); a missing or invalid key returns 401. Enforcement is fail-closed: if the key-management layer (Unkey) is unavailable during provisioning, the request is rejected (502) rather than silently allowed.

What's included:

  • Customer records in Neon PostgreSQL (src/aegis_governance/customer_pg.py)
  • Per-evaluation usage metering (one INSERT per call; PostgreSQL handles concurrency)
  • Per-tier rate limiting and monthly-evaluation quotas, enforced via Unkey (HTTP 429 on rate/quota breach, fail-closed)
  • Admin CLI for customer CRUD and usage queries
  • Cloud Run FastAPI integration with response-body customer-context enrichment
  • Operator provisioning via POST /customer/provision (creates the Unkey key)

What's NOT included:

  • Self-service customer portal (handled by the separate Portal app)
  • Billing or invoicing UI (Stripe drives subscription state via webhooks)
  • Customer-facing dashboards (served by the Portal)

2. How It Works

HTTPS request to api.aegis.undercurrentholdings.com (Cloud Run)

  ├── Authorization: Bearer <unkey_key>  (or X-Api-Key: <unkey_key>)


verify_api_key (cloud_run_auth.py)

  ├── Unkey verifyKey → per-key rate-limit + remaining-credit check
  │     (429 if over the tier rate limit or monthly quota)
  │     (401 if the key is missing / invalid; 503 if Unkey unconfigured)

  ├── customer_id + tier extracted from the auth context
  │     (Unkey identity.externalId → customer_id; meta.tier → tier)

  ├── Neon lookup of the customer record (customer_pg.CustomerManager)

  ├── Governance evaluation (pcw_decide) — unaffected by lookup/metering

  ├── Usage recorded in Neon: INSERT into aegis_usage_records
  │     (best-effort — rolled back and logged on failure, never blocks)

  └── Response enriched with customer context (response body)
        body._customer_id: cust_abc123
        body._request_id:  <uuid>
        body._quota_used / _quota_limit / _quota_percent

Key design principle: Customer lookup and usage metering failures never block governance evaluation. The request handler catches exceptions from the customer subsystem, rolls back the session, and logs warnings — the governance decision is returned regardless. (Rate-limit and quota enforcement, by contrast, happen before the decision, at the Unkey verification step.)


3. Neon Postgres Data Model

All customer data lives in Neon PostgreSQL via SQLAlchemy ORM. The models are defined in src/aegis_governance/models_pg.py; the manager and dataclasses are in customer_pg.py / customer_models.py. All tables are prefixed aegis_ to avoid collision with sibling services (e.g. AFA) sharing the Neon database.

TableModelKey columns
aegis_customersCustomerModelcustomer_id (PK), name, email, company, tier, status, api_key_id, created_at, updated_at, metadata_json
aegis_api_key_mappingsApiKeyMappingModelapi_key_id (PK) → customer_id
aegis_usage_recordsUsageRecordModelid (PK), customer_id, month (YYYY-MM), day (DD), evaluate_count, risk_check_count, total_calls, channels_json
aegis_subscriptionsSubscriptionModelcustomer_id (PK), stripe_customer_id, stripe_sub_id, status, current_period_start/end, cancel_at_period_end
aegis_decisionsDecisionModelid (PK), customer_id, decision_id (unique), agent_id, proposal_json, result_json, outcome, timestamp

The public API of CustomerManager returns the dataclasses Customer, Subscription, UsageRecord, and UsageSummary (defined in customer_models.py), decoupled from the ORM layer.

Customer IDs follow Stripe convention: cust_ prefix + 12-character hex (e.g., cust_a1b2c3d4e5f6).

Tiers: community, professional, enterprise, financial_services (canonical; free/pro are accepted aliases for community/professional). See tiers.py for per-tier rate limits, monthly-evaluation quotas, and feature flags.

Status: active, suspended, past_due, or canceled.


4. Creating Customers

Via Admin CLI

When DATABASE_URL is set, the admin CLI automatically selects the Neon-backed CustomerManager (customer_pg.py); otherwise it falls back to the legacy DynamoDB backend. The Cloud Run runtime always has DATABASE_URL set.

# Minimal (community tier, no API key association)
aegis admin create-customer \
  --name "Acme Corp" \
  --email "admin@acme.example.com"

# Full options
aegis admin create-customer \
  --name "Acme Corp" \
  --email "admin@acme.example.com" \
  --company "Acme Inc." \
  --tier professional \
  --api-key-id "abc123xyz"

Output:

{
  "customer_id": "cust_a1b2c3d4e5f6",
  "name": "Acme Corp",
  "email": "admin@acme.example.com",
  "company": "Acme Inc.",
  "tier": "professional",
  "status": "active",
  "api_key_id": "abc123xyz",
  "created_at": "2026-06-03T12:00:00+00:00",
  "updated_at": "2026-06-03T12:00:00+00:00",
  "metadata": {}
}

The admin CLI creates a customer record but does not mint an Unkey key. To issue an enforced API key, use the provisioning endpoint below (it both creates the record, if needed, and mints the Unkey key with the tier's rate limit).

Via Provisioning Endpoint (canonical operator path)

The operator path for issuing a customer + enforced API key is POST /customer/provision on the REST API. It is authenticated with the X-Service-Key header (server-to-server) and creates an Unkey key whose per-minute rate limit is set from the tier definition. This is how the Portal provisions customers.

curl -sS -X POST https://api.aegis.undercurrentholdings.com/customer/provision \
  -H "X-Service-Key: ${AEGIS_SERVICE_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "clerk_user_id": "user_abc123",
    "email": "admin@acme.example.com",
    "name": "Acme Corp",
    "tier": "professional"
  }'

The response returns the customer ID and the minted API key value (the raw key is returned once at creation):

{
  "customer_id": "cust_a1b2c3d4e5f6",
  "api_key": "uk_live_...",
  "api_key_id": "key_..."
}

Pass "force_reprovision": true to revoke the customer's existing Unkey keys and mint a new one. For a pure key rotation, omit the tier field — the customer's tier is left unchanged. Re-provisioning with a tier that differs from the customer's current tier returns 409 Conflict: tier changes are not made here, they must go through the Stripe subscription (the webhook updates the billing tier and the Unkey rate limits).

Programmatic

from aegis_governance.customer_pg import CustomerManager

# Reads DATABASE_URL from the environment for the Neon connection
mgr = CustomerManager()
customer = mgr.create_customer(
    name="Acme Corp",
    email="admin@acme.example.com",
    tier="professional",
    api_key_id="abc123xyz",
)
print(customer.customer_id)  # cust_a1b2c3d4e5f6

5. Querying Usage

Via Admin CLI

# Current month usage
aegis admin usage cust_a1b2c3d4e5f6

# Specific month
aegis admin usage cust_a1b2c3d4e5f6 --month 2026-03

Output:

{
  "customer_id": "cust_a1b2c3d4e5f6",
  "month": "2026-03",
  "total_evaluations": 142,
  "total_risk_checks": 28,
  "total_calls": 170,
  "channels": {},
  "daily_breakdown": [
    {
      "day": "01",
      "evaluate_count": 12,
      "risk_check_count": 3,
      "total_calls": 15
    }
  ]
}

Direct Database Query

Usage data lives in Neon PostgreSQL. For ad-hoc queries or reporting, use the aegis admin usage CLI (above) or query the aegis_usage_records table directly (the aegis_usage_records daily rows aggregate into the UsageSummary returned by get_usage):

-- All usage records for a customer in March 2026
SELECT day, evaluate_count, risk_check_count, total_calls
FROM aegis_usage_records
WHERE customer_id = 'cust_a1b2c3d4e5f6'
  AND month = '2026-03'
ORDER BY day;

6. Response Headers

When a request is made with a known API key, the Cloud Run FastAPI app adds customer context to the response:

Headers (set by _json_response in src/api_server.py):

HeaderExampleDescription
X-AEGIS-Request-IduuidRequest tracking ID (always present)
X-AEGIS-Version4.7.xServed API version (always present)
X-AEGIS-Idempotent-ReplayedtruePresent when an idempotent /evaluate request was served from the dedup cache
X-AEGIS-Idempotency-Cachehit | missIdempotency-cache status (when an Idempotency-Key is supplied)

Body fields (appended to the JSON response body):

FieldExampleDescription
_customer_id"cust_a1b2c3d4e5f6"Customer ID (only if known)
_request_id"uuid"Request tracking ID (always present)
_quota_used4200Monthly evaluations consumed (when quota tracking is active)
_quota_limit10000Monthly evaluation cap for the tier
_quota_percent42.0Percent of the monthly quota used

For anonymous requests (no API key or unknown key), _customer_id is omitted; _request_id and the X-AEGIS-Request-Id / X-AEGIS-Version headers are still present.


7. Cloud Run Integration

The FastAPI app (src/api_server.py) runs on GCP Cloud Run and integrates customer management on every authenticated request:

  1. Auth dependency: verify_api_key (src/aegis_governance/cloud_run_auth.py) is a FastAPI Depends that extracts the token from Authorization: Bearer <token> (or the X-Api-Key header) and verifies it with Unkey. Rate-limit and monthly-quota breaches surface here as 429 (with RateLimit-* headers; Retry-After only for short-window rate-limit throttles, omitted for monthly-quota exhaustion); missing/invalid keys as 401; an unconfigured Unkey client as 503.
  2. Auth context: A successful verification yields an AuthContext carrying customer_id (from Unkey identity.externalId) and tier (from the key's meta.tier).
  3. Customer lookup: Where customer details are needed, CustomerManager (customer_pg.py) resolves the record from Neon. A small in-process _customer_cache (keyed by api_key_id) avoids repeat lookups.
  4. Governance evaluation: pcw_decide() runs independently — customer lookup cannot affect it.
  5. Usage recording: One INSERT into aegis_usage_records per call, best-effort (rolled back and logged on failure, never blocks the response).
  6. Response enrichment: Customer context (_customer_id, _request_id, and _quota_* fields) is injected into the JSON response body; X-AEGIS-Request-Id and X-AEGIS-Version are set as headers.

Connection handling: CustomerManager holds a single long-lived SQLAlchemy session and recovers from Neon's scale-to-zero SSL teardown via _exec() retry-on-OperationalError (close the poisoned session, reopen, retry once).


7.5. Quota Lifecycle & Billing-Cycle Resets

How a customer's monthly-evaluation quota (Unkey credits.remaining) changes across the subscription lifecycle (hardened by ROADMAP G57, 2026-06-12):

EventQuota effect
invoice.paid with billing_reason in subscriptionFull reset to the tier ceiling — the customer paid for a fresh cycle. Idempotent per invoice id (last_reset_invoice key-meta token): duplicate deliveries keep consumed credits.
invoice.paid with any other billing_reason (subscription_update proration, manual, ...)No reset — keys are synced (ratelimit/meta) but credits are untouched; a mid-cycle plan-change invoice must not forgive month-to-date usage.
Subscription event for a tier the key is already on (duplicate created, renewal-time updated, payment-method changes)No credit write — ratelimit + meta refresh only. Duplicate webhook deliveries can no longer refill consumed quota.
Tier transition (upgrade / downgrade / suspension / recovery)Grant = new_ceiling - month_to_date_usage, clamped at 0. Usage is tracked via mtd_used/mtd_granted key-meta anchors, so a suspension or downgrade round-trip restores the customer's exact position.
Community (free) tierNo Stripe invoices — the monthly cron (POST /admin/reset-community-quotas, service-key auth, 1st of month) performs the cycle reset, refreshing ratelimit + anchors and skipping customers who upgraded mid-run.

Failure semantics: a failed invoice.paid quota reset returns HTTP 503 and un-marks the event from webhook dedup, so Stripe re-delivers (for up to 3 days) instead of the customer silently stranding at 0 credits for a cycle. All other webhook failures acknowledge with 200 (BH53-B5 — no retry storms).

Troubleshooting "renewal didn't reset my quota": check the invoice's billing_reason (proration/manual invoices deliberately don't reset), then the key's last_reset_invoice meta (was this invoice already applied?), then Cloud Run logs for Invoice paid: ... FAILED / 503 lines (Stripe is retrying).


8. Troubleshooting

DATABASE_URL Not Set

{"error": "Missing dependency: ..."}

The Neon-backed CustomerManager requires DATABASE_URL (a postgresql+asyncpg://... / postgresql://... connection string) to be set, plus the [persistence] extra installed (pip install -e ".[persistence]"). On Cloud Run, DATABASE_URL is injected from Secret Manager. Without it, the CLI falls back to the legacy DynamoDB backend.

Database Connection Errors

sqlalchemy.exc.OperationalError: SSL connection has been closed unexpectedly

Neon serverless suspends compute after inactivity, tearing down the SSL connection. CustomerManager._exec() already retries once on OperationalError (close the stale session, reopen, retry). Persistent failures usually mean DATABASE_URL is wrong, the Neon project is paused, or the network path to Neon is blocked — verify the connection string and Neon project status.

Anonymous / Unauthenticated Requests

A request made without an API key (or with a key not recognized by Unkey) is rejected with 401. If you expected the request to be authenticated, confirm the key was issued via /customer/provision and has not been revoked.

Customer Not Found

{"error": "Customer not found: cust_abc123"}

Verify the customer exists:

aegis admin list-customers

If you only minted an Unkey key but no matching record exists, re-run /customer/provision (it creates the record if missing) or aegis admin create-customer --api-key-id <KEY_ID>.

Usage Shows Zero

Usage is recorded per API route (/evaluate and /risk-check). If a customer has been created but never called the API, usage will be zero. Verify the API key mapping exists by checking that aegis admin get-customer <ID> shows a non-empty api_key_id.


9. Roadmap Status

The customer-management capabilities below are shipped in the live Cloud Run + Neon + Unkey stack:

CapabilityScopeStatus
Self-serviceCustomer Portal, Unkey API keys, usage dashboardsShipped
Rate limitingPer-tier per-minute rate limits via Unkey (429 on rate/quota breach)Shipped
MonetizationStripe Billing, per-tier monthly-evaluation quotas, tiered pricingShipped

See ADR-008 for the original strategy rationale.


References

On this page