new-site/docs/architecture.md
justin b48d0cb799 docserver: self-healing Task Scheduler config + docs
Companion to the worker MinIO-retry fix. Makes the worker auto-recover from
process death (crash, manual kill, missed boot trigger), not just MinIO outages.

- start_worker.bat: propagate Python's exit code (exit /b %rc%) so Task
  Scheduler can actually detect a failed run (it previously always exited 0).
- reconfigure_task.ps1 (new): re-registers PW-DocserverWorker with
  RestartCount=99 / 1-min interval, StartWhenAvailable, and two triggers —
  AtStartup plus a 5-min repeating trigger with MultipleInstances=IgnoreNew, so
  a dead worker relaunches within ~5 min and never double-runs. Idempotent.
- install.ps1: same self-healing settings for fresh installs.
- Verified on the box: killed the worker -> task relaunched it; firing again
  while running stayed at one instance.

Docs updated to match reality:
- docserver/README.md: new 'Reliability / self-healing' section.
- document-generation.md: corrected the stale 'Flask DocServer :5050 / HTTP'
  description to the actual MinIO outbound-only transport.
- e2e-test-plan.md: removed the outdated 'Word COM fails under SYSTEM / requires
  RDP after every reboot' limitation; now self-healing under SYSTEM session 0.
- infrastructure.md: fixed VM spec (Win Server 2019, Word 16.0, Python 3.13,
  SSH port 22422) + self-healing note.
- architecture.md / formation-system.md: trigger + self-healing details.
2026-06-15 22:49:21 -05:00

387 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# System Architecture
**Last updated:** 2026-05-07 (15 Docker containers + k3s SHKeeper pods + Windows DocServer + Postfix/OpenDKIM + bounce watcher + dev stack)
See also:
- [Business Flow Diagram](business-flow.svg)
- [Technical Architecture Diagram](technical-architecture.svg)
- [Order Processing Flowchart](order-flow.svg)
## Overview
Performance West runs as a multi-service stack on a Debian 13 VM (207.174.124.71):
- **Astro static site** — marketing pages, service descriptions, CRTC order form, crypto payment page
- **Express API** — order creation, Stripe/PayPal/SHKeeper checkout, identity verification, webhook receiver, portal setup API
- **ERPNext** — CRM, orders, invoicing, ticketing, customer portal (v15 + MariaDB, custom image with 4 Frappe apps). Portal at `portal.performancewest.net`
- **Listmonk** — email marketing at `lists.performancewest.net` (Go binary, PostgreSQL-backed). 22 campaigns across 4 lists. Bounce processing via POP3 from Carbonio.
- **MinIO** — S3-compatible document storage (formation docs, compliance reports, binder PDFs)
- **Ollama** — local LLM for document generation and email drafting (qwen2.5:7b)
- **Workers** — Python automation: Anytime Mailbox signup (Playwright + IMAP OTP), BC incorporation, Flowroute DID provisioning, document generation, AMB location scraping, payment reminders, GCKey provisioning (Playwright), compliance calendar renewal lifecycle, client email processing
- **SHKeeper** — self-hosted crypto payment processor via k3s (BTC, LTC, DOGE, ETH, BNB, MATIC, TRX). TRX uses nginx proxy at :5555 → TronGrid with API key injection. ETH uses proxy at :5556 → publicnode.
- **Crypto Treasury** — post-payment pipeline: SHKeeper → Bridge (Stripe) offramp → RelayFi bank → Relay debit card → vendor payments. Manual mode until Bridge approval.
- **Umami** — self-hosted web analytics at `analytics.performancewest.net`
- **PostgreSQL** — API state, orders, identity verifications, AMB locations, sessions, jurisdictions, foreign qualifications, crypto ledger (api-postgres; also hosts Listmonk DB). 67 migrations applied.
- **Dev stack** — separate environment at `dev.performancewest.net` / `api.dev.performancewest.net` (port 4323/3002, own PG on 5433). Systemd unit: `performancewest-dev.service`.
## Payment Gateway Architecture
**Active gateways:** Stripe (card/ACH/Klarna), PayPal Direct (Orders API v2), SHKeeper (crypto).
| Gateway | Status | Methods | Surcharge |
|---------|--------|---------|-----------|
| Stripe | **Active** | Card (+3%), ACH (0%), Klarna (+6%) | Via Stripe Checkout Sessions |
| PayPal | **Active** | PayPal Direct (+3%) | Orders API v2 with capture/tracking/refund |
| SHKeeper | **Active** | Crypto (0%) — BTC/LTC/DOGE + EVM chains | Branded `/order/crypto-pay` page with coin selector |
| Adyen | Future | Card/ACH/Klarna/CashApp/AmazonPay (frappe_adyen installed, not configured) |
**Payment card routing for vendor expenses:**
| Customer paid with | Filing card | ERPNext SID | Funds timing |
|---|---|---|---|
| Card/ACH/Klarna | Stripe Issuing virtual card | SID-0002 | T+2 card, T+4 ACH |
| PayPal | PayPal Mastercard | SID-0001 | Instant |
| Crypto | crypto-filing-card | crypto-filing-card | Manual |
**Fund availability detection:** Stripe `balance.available` webhook checks settlement timing (T+2 card/Klarna, T+4 ACH). PayPal is instant. Crypto is manual. When funds clear: Stripe Issuing topup → advance to "Client Selection" → email client portal setup link.
ERPNext custom Frappe apps (baked into `performancewest-erpnext:latest`):
| App | Purpose |
|-----|---------|
| `frappe_crypto` | SHKeeper crypto gateway |
| `frappe_adyen` | Adyen gateway (future) |
| `frappe_ca_registry` | BC Corporate Online incorporation automation |
| `performancewest_erpnext` | Surcharge hooks, identity gate, custom DocTypes |
## Service Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Proxmox Host │
│ │
│ ┌── Linux VM (Debian 13) — 207.174.124.71 ───────────────┐ │
│ │ │ │
│ │ Docker Compose (15 containers) │ │
│ │ ├── site (Astro → nginx:alpine) :4322 │ │
│ │ ├── api (Express/TypeScript) :3001 │ │
│ │ ├── api-postgres (app data) :5432 │ │
│ │ ├── erpnext (custom image + 4 apps) :8080 │ │
│ │ ├── erpnext-worker-default │ │
│ │ ├── erpnext-worker-short │ │
│ │ ├── erpnext-scheduler │ │
│ │ ├── erpnext-mariadb :3306 │ │
│ │ ├── erpnext-redis :6379 │ │
│ │ ├── listmonk (email marketing) :9100 │ │
│ │ ├── minio (document storage) :9000/:9001 │ │
│ │ ├── workers (Python automation) :8090 │ │
│ │ ├── ollama (local LLM) :11434 │ │
│ │ ├── umami (analytics) :3100 │ │
│ │ └── umami-postgres │ │
│ │ │ │
│ │ Dev Stack (docker compose at /opt/performancewest-dev) │ │
│ │ ├── dev-site (Astro → nginx:alpine) :4323 │ │
│ │ ├── dev-api (Express/TypeScript) :3002 │ │
│ │ ├── dev-api-postgres :5433 │ │
│ │ └── dev-workers (Python automation) │ │
│ │ │ │
│ │ k3s / Kubernetes (SHKeeper crypto payments) │ │
│ │ ├── shkeeper-deployment (Flask API) NodePort :30723 │ │
│ │ ├── bitcoin-shkeeper (3 containers) │ │
│ │ ├── ethereum-shkeeper (3 containers) via :5556 proxy │ │
│ │ ├── polygon-shkeeper (3 containers) │ │
│ │ ├── bnb-shkeeper (3 containers) │ │
│ │ ├── tron-shkeeper (3 containers) via :5555 proxy │ │
│ │ ├── litecoin-shkeeper (3 containers) │ │
│ │ ├── dogecoin-shkeeper (3 containers) │ │
│ │ └── mariadb (SHKeeper database) │ │
│ │ │ │
│ │ nginx RPC Proxies (inject API keys, strip basic auth) │ │
│ │ ├── :5555 → api.trongrid.io + TRON-PRO-API-KEY │ │
│ │ └── :5556 → ethereum-rpc.publicnode.com │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌── Windows VM (DocServer) — 108.181.102.34 ─────────────┐ │
│ │ SSH: port 22422 (key auth) │ │
│ │ Office 365 Word (COM automation) │ │
│ │ Python 3.13 + pywin32 + minio SDK │ │
│ │ docserver_worker.py (MinIO poller, 12s interval) │ │
│ │ Task Scheduler: PW-DocserverWorker (AtStartup + 5-min) │ │
│ │ Self-healing: restart-on-fail + MinIO-retry (no RDP) │ │
│ │ Private network: 10.4.20.247 → MinIO via nginx │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## DNS
| Domain | Target |
|--------|--------|
| `performancewest.net` | site (:4322) |
| `api.performancewest.net` | api (:3001) |
| `crm.performancewest.net` | ERPNext (:8080) |
| `lists.performancewest.net` | Listmonk (:9100) — email marketing |
| `portal.performancewest.net` | ERPNext (:8080) — client portal |
| `analytics.performancewest.net` | Umami (:3100) |
| `pay.performancewest.net` | SHKeeper API (:5000 via k3s NodePort :30723) |
| `crypto.performancewest.net` | SHKeeper admin UI (:30723 via nginx) |
| `dev.performancewest.net` | Dev site (:4323) |
| `api.dev.performancewest.net` | Dev API (:3002) |
| `minio.performancewest.net` | MinIO S3 API (:9000) |
| `minio-console.performancewest.net` | MinIO Console (:9001) |
All A records point to `207.174.124.71`. TLS via Let's Encrypt (certbot, 8 certs). Mail records unchanged (HestiaCP at `207.174.124.15`).
**External panels:**
| Domain | Service |
|--------|---------|
| `cp.carrierone.com` | HestiaCP — DNS, .ca domain/email provisioning |
## Data Flow
```
Browser
├── performancewest.net ──────> nginx ──> site (static pages)
├── api.performancewest.net ──> nginx ──> Express API
│ ├── api-postgres (state fees, API keys)
│ ├── ERPNext REST API (CRM, orders, tickets)
│ ├── Listmonk API (email marketing)
│ └── Workers HTTP API (job dispatch)
├── crm.performancewest.net ──> nginx ──> ERPNext
│ ├── erpnext-mariadb
│ └── erpnext-redis
├── pay.performancewest.net ──> nginx ──> SHKeeper (k3s :5000)
├── lists.performancewest.net ──> nginx ──> Listmonk (:9100)
├── portal.performancewest.net ──> nginx ──> ERPNext (:8080)
├── minio.performancewest.net ──> nginx ──> MinIO S3 (:9000)
└── minio-console.performancewest.net ──> nginx ──> MinIO Console (:9001)
```
## Order Flow (ERPNext BPM)
```
Customer places order → Express API → ERPNext Sales Invoice + Payment Request
→ ERPNext payment gateway (Adyen or SHKeeper) → Customer pays
→ ERPNext webhook → Express API → Workers job server
→ Workers execute: name search → filing → EIN → doc gen
→ Documents uploaded to MinIO
→ ERPNext status → Review → Admin approves → Delivered
```
## Key Integration Points
| From | To | Method | Purpose |
|------|----|--------|---------|
| Website | Express API | HTTPS | Forms, orders, name search |
| Express API | ERPNext | REST API | CRM, orders, invoicing, tickets |
| Express API | Workers | HTTP :8090 | Job dispatch (via webhooks) |
| Express API | Listmonk | REST API | Subscriber onboarding, email campaigns |
| ERPNext | Express API | Webhooks | Workflow state change notifications |
| ERPNext | Adyen | Sessions API v71 | Card/ACH/Klarna/CashApp/AmazonPay (via frappe_adyen) |
| ERPNext | SHKeeper | REST API :5000 | Crypto payment requests (via frappe_crypto) |
| Workers | ERPNext | REST API | Status updates, workflow advances |
| Workers | MinIO | S3 API | Document upload/download |
| Workers | Ollama | HTTP :11434 | LLM doc generation, email drafting |
| Workers | DocServer | MinIO transport | DOCX-to-PDF conversion (MinIO poller: to-convert/ → converted/) |
| Workers | State portals | Playwright | Name search, entity filing |
| Workers | IRS | Playwright | EIN obtainment |
| ERPNext | Email | SMTP | Invoices, notifications, ticket replies |
| Relay debit card | State portals | Playwright | Filing fee payment |
| Workers | HestiaCP | SSH | .ca domain/email provisioning (cp.carrierone.com) |
| Workers | Flowroute | REST API | Canadian DID provisioning |
| Workers | Porkbun | REST API | .ca domain registration |
| Workers | Client mailboxes | IMAP | Monitor/process client .ca email |
| Website | Anytime Mailbox | External | Vancouver mailbox for Canadian carrier clients |
| ERPNext | Venn.ca | Referral | Canadian business banking referral |
| AI Agents | MCP Server | stdio | Service lookup, pricing, tools (npm package) |
## Worker Processes (Python)
The `workers` container runs a job server (`job_server.py` on port 8090) that dispatches jobs
to specialized worker modules. Jobs are triggered by ERPNext webhooks via the Express API.
| Worker | File | Trigger | Purpose |
|--------|------|---------|---------|
| Job Server | `job_server.py` | HTTP POST :8090 | Central dispatcher — 22 job handlers |
| CRTC Pipeline | `services/canada_crtc.py` | Webhook | 14-step CRTC carrier registration pipeline |
| GCKey Provisioner | `gckey_provisioner.py` | Called by CRTC Step 11 | Playwright-based GCKey account creation (5-step wizard, hCaptcha) |
| Renewal Worker | `renewal_worker.py` | Daily cron (7 AM) | Compliance calendar lifecycle: upcoming → due soon → invoice → paid → completed → re-calendar |
| Formation Worker | `formation_worker.py` | Webhook | US state LLC/Corp filing via Playwright |
| Binder Compiler | `binder_compiler.py` | Called by pipeline | PDF merge: certificate + articles + CRTC letter + OA |
| Document Gen | `document_gen/` | Called by pipeline | DOCX template fill → MinIO → DocServer PDF conversion |
| HestiaCP Provisioner | `hestia_provisioner.py` | Called by pipeline | .ca domain + 14 mailboxes via SSH to cp.carrierone.com |
| Client Email Processor | `client_email_processor.py` | Cron (15 min) | IMAP monitor for client .ca mailboxes |
| AMB Scraper | `amb_location_scraper.py` | Daily cron | Anytime Mailbox location pricing + sold-out detection |
| Payment Reminder | `payment_reminder.py` | Cron (5 min) | Abandoned cart recovery (15min/1d/2d intervals) |
| Commission Worker | `commission_worker.py` | Cron (daily 02:00) | Agent commission eligibility check (14-day holdback) |
| Crypto Payment Worker | `crypto_payment_worker.py` | Cron (60s) | Treasury state machine: received → sizing → offramping → funds_at_relay → settled |
| Cold Wallet Sweeper | `cold_wallet_sweeper.py` | Cron (30 min) | Sweep SHKeeper hot wallet excess to cold storage |
| Relay Deposit Monitor | `relay_deposit_monitor.py` | Cron (5 min) | IMAP parser for Relay bank deposit alerts |
| USF Factor Monitor | `usf_factor_monitor.py` | Cron (daily 09:00 CT) | Scrape USAC quarterly USF factor, email all FCC carriers |
| De Minimis Check | `deminimis_factor_check.py` | Cron (daily 03:00) | Alert if fcc_deminimis_factors missing for current/next year |
| CDR Retention Sweeper | `cdr_retention_sweeper.py` | Cron (daily 05:00) | Purge CDR data past retention window |
| CDR Unlock Nudge | `cdr_unlock_nudge.py` | Cron (daily 10:00 CT) | Email customers with locked CDR studies behind paywall |
| FCC RMD Scraper | `fcc_rmd_removed_scraper.py` | Cron (weekly Wed) | Track FCC RMD carrier removals |
| Foreign Qual Handler | `services/foreign_qualification.py` | Webhook | Multi-state Certificate of Authority filings |
| New Carrier Bundle | `services/new_carrier_bundle.py` | Webhook | 6-handler chain: CORES → DC Agent → 499 Init → RMD → CPNI → CALEA |
### CRTC Pipeline Steps (14 total)
| Step | Name | Key Actions |
|------|------|-------------|
| 1 | Order Received | Validate, create ERPNext Sales Order |
| 2 | Payment Confirmed | Create Sales Invoice + Payment Entry |
| 3 | Client Selection | Client picks mailbox unit, DID, domain |
| 4 | BC Incorporation | Playwright → BC Corporate Online |
| 5 | Domain Registration | Porkbun .ca + HestiaCP provisioning |
| 6a | CRTC Letter Generation | DOCX template → MinIO → DocServer PDF |
| 6b | eSign | Email JWT link → client signs → resume pipeline |
| 7 | CRTC Submission | Mail signed letter to Secretary General |
| 8 | Anytime Mailbox | Playwright signup + IMAP OTP |
| 9 | Binder Compilation | PDF merge of all documents |
| 10 | Delivery | Email binder + admin print/ship email |
| 11 | BITS Registration | GCKey provisioning + admin ToDo for BITS filing |
| 12 | CCTS Membership | Admin ToDo + client obligations email |
| 13 | Compliance Calendar | Create 17 compliance entries (regulatory + tax + ATS) |
| 14 | Ready for Review | Final admin review before marking Delivered |
### Compliance Calendar Renewal Lifecycle
The `renewal_worker.py` runs daily at 7 AM and manages the full lifecycle of recurring
compliance obligations for all carriers:
```
Upcoming → Due Soon (30 days out) → Invoice Sent → Paid → Completed → auto-re-calendar next year
```
- **17 entries per carrier:** BC annual report, CRTC annual maintenance, mailbox renewal,
domain renewal, DID renewal, CCTS renewal, T2 tax return, corporate tax payment,
GST/HST return, T4/T4A slips, BC PST, WorkSafeBC, CRTC registration update,
plus ATS survey forms (REP-T/T1 mandatory for all carriers)
- **Billable items** generate ERPNext Sales Invoice; entry cannot be completed until paid
- **On payment:** entry marked Completed, admin ToDo created, next-year entry auto-created
- **Webhook-triggered:** `handle_renewal_payment` in job_server for immediate processing
### DocServer (Windows VM)
MinIO-based transport — no direct HTTP connection between Linux and Windows VMs.
```
Workers: upload DOCX to minio://performancewest/to-convert/{uuid}.docx
→ DocServer polls to-convert/ bucket every 12 seconds
→ Word COM converts DOCX → PDF
→ DocServer uploads PDF to minio://performancewest/converted/{uuid}.pdf
→ Workers poll converted/ bucket for result
```
- **Heartbeat:** DocServer writes `docserver-heartbeat.json` to MinIO every 60 seconds
- **Fallback:** If heartbeat is stale (>5 min), workers auto-switch to LibreOffice headless
- **Self-healing:** the worker retries MinIO with backoff instead of exiting on an
outage; the `PW-DocserverWorker` task restarts on failure (99×/1 min) and has a
5-min repeating trigger, so a crash/missed boot self-recovers without RDP. See
`docserver/README.md`.
## Boot Sequence
All services auto-start on reboot via systemd:
| Unit | What it starts | Depends on |
|------|----------------|------------|
| `docker.service` | Docker daemon | network |
| `k3s.service` | k3s + all SHKeeper pods | docker |
| `nginx.service` | Reverse proxy + TLS + RPC proxies (:5555/:5556) | network |
| `performancewest.service` | Prod docker compose (15 containers) | docker |
| `performancewest-dev.service` | Dev docker compose (4 containers) | docker, performancewest |
All containers use `restart: unless-stopped`. k3s manages pod restarts via deployment specs. The nginx RPC proxy configs for TronGrid (:5555) and ETH (:5556) are in `/etc/nginx/conf.d/` and load automatically. UFW rules for ports 5555/5556 are persistent (allow from 10.42.0.0/16 only).
## Scheduled Workers (systemd timers)
Deployed via `infra/ansible/roles/worker-crons/`. Each timer runs `docker compose exec -T workers python -m <module>`.
| Timer | Cadence | Module |
|---|---|---|
| `pw-usf-factor-monitor` | daily 09:00 CT | `scripts.workers.usf_factor_monitor` |
| `pw-deminimis-factor-check` | daily 03:00 UTC | `scripts.workers.deminimis_factor_check` |
| `pw-cold-wallet-sweep` | every 30 min | `scripts.workers.cold_wallet_sweeper` |
| `pw-crypto-payment-worker` | every 60s | `scripts.workers.crypto_payment_worker` |
| `pw-relay-deposit-monitor` | every 5 min | `scripts.workers.relay_deposit_monitor` |
| `pw-commission-worker` | daily 02:00 UTC | `scripts.workers.commission_worker` |
| `pw-renewal-worker` | daily 04:00 UTC | `scripts.workers.renewal_worker` |
| `pw-cdr-retention` | daily 05:00 UTC | `scripts.workers.cdr_retention_sweeper` |
| `pw-cdr-unlock-nudge` | daily 10:00 CT | `scripts.workers.cdr_unlock_nudge` |
| `pw-payment-reminder` | daily 11:00 CT | `scripts.workers.payment_reminder` |
| `pw-fcc-rmd-removed` | weekly Wed 08:00 CT | `scripts.workers.fcc_rmd_removed_scraper` |
| `pw-client-email-processor` | every 15 min | `scripts.workers.client_email_processor` |
| `pw-amb-location-scraper` | daily 06:00 UTC | `scripts.workers.amb_location_scraper` |
## Database Schema (67 migrations)
Key tables added in this cycle:
| Table | Migration | Purpose |
|---|---|---|
| `jurisdictions` | 066 | Unified US states + CA provinces (55 rows) |
| `foreign_qualification_registrations` | 066 | Per-state COA filings |
| `state_compliance_obligations` | 067 | Annual report fees/dates for all 51 US jurisdictions |
| `crypto_payment_ledger` | 062 | Immutable append-only money movement ledger |
| `crypto_payment_jobs` | 065 | Treasury state machine (received → settled) |
| `vendor_obligations` | 063 | Sizer: filing fees + commission reserves |
| `cold_wallet_config` / `cold_wallet_sweeps` | 064 | Cold wallet management |
## FCC Compliance Check Tool
Public at `/tools/fcc-compliance-check`. Queries FRN against CORES, RMD, 499 Filer DB, and CPNI records. Displays:
- CORES registration status (red-light check)
- RMD filing + certification date
- STIR/SHAKEN implementation (self-reported from RMD, hidden until STI-PA API access)
- CPNI annual certification (past-due detection with correct deadline logic)
- Form 499-A annual filing (CY-year-aware)
- Form 499-Q quarterly (de minimis trade-off explanation)
- BDC broadband/voice interactive toggle (two-step questionnaire)
Remediation CTA links to `/order/fcc-compliance?services=cpni,499a,...` for bundled ordering with 15% discount.
## Compliance Service Catalog (34 handlers)
| Slug | Handler | Price |
|---|---|---|
| `new-carrier-bundle` | NewCarrierBundleHandler (6 sub-handlers) | $1,799 |
| `fcc-full-compliance` | FullComplianceHandler | $1,499 |
| `fcc-499a` | Form499AHandler | $499 |
| `fcc-499a-499q` | Form499ABundleHandler | $599 |
| `cpni-certification` | CPNIFilingHandler (9 category variants) | $149 |
| `rmd-filing` | RMDFilingHandler | $219 |
| `calea-ssi` | CALEASSIHandler (6 category variants) | $299 |
| `bdc-filing` / `bdc-broadband` / `bdc-voice` | BDCFilingHandler | $299/$199/$149 |
| `foreign-qualification-single` | ForeignQualificationHandler | $149 + state fees |
| `foreign-qualification-multi` | ForeignQualificationHandler | $99/state + fees |
| `dc-agent` | DCAgentHandler (NWRA wholesale) | $99/yr |
| `cores-frn-registration` | CORESFRNRegistrationHandler | $99 |
| `fcc-499-initial` | Form499InitialHandler | $299 |
| `ocn-registration` | OCNRegistrationHandler | $799 |
## State Formation Adapters
10 states with real adapters (name search via Playwright + portal config):
WY (237 lines), CO (177), DE (119), FL (118), TX (SOSDirect), NV (SilverFlume), UT (DCC), NM, OH, MT.
~40 remaining states have stub adapters that create admin todos for manual filing.
JurisdictionConfig abstraction at `scripts/formation/jurisdictions/` reads from the `jurisdictions` DB table (migration 066) and provides per-state fees, portal URLs, NWRA addresses, and entity type catalogs.
## Deployment
**Production:** `ansible-playbook infra/ansible/playbooks/deploy.yml -i infra/ansible/inventory/hosts.yml`
**Dev:** `bash scripts/deploy-dev.sh` — rsyncs source files + rebuilds Docker containers. Static pages (tools, services, homepage) are in `site/public/` and survive Astro rebuilds.
**nginx cache policy:** `_astro/` = immutable (1 year). HTML = no-cache. Images = 30 days.
- **Atomic uploads:** `.tmp_` prefix + `copy_object` rename prevents partial reads