FDX is a multi-tenant event-photo delivery platform. Organizations upload event photographs and participant lists; FDX builds a persistent face index, matches consenting participants from their selfies, and delivers private galleries. A single login serves Super Admins, Organization Admins, and permission-restricted Staff. Participants use expiring enrollment and gallery links without creating an account.
Event photos are analyzed when they are processed. Their detected faces receive event-scoped unique IDs and are stored in PostgreSQL with embeddings and photo associations. A later selfie needs one inference request to create its own embedding; matching then queries the stored event index. Viewing an existing gallery does not run the models.
The product workflow is documented in docs/workflow.md, the technical requirements in docs/specs.md, and the implementation map in docs/spec-implementation.md. See the validation report for checks performed and deployment limits.
- Manages organizations, administrator invitations, staff permissions, storage quotas, and retention policies.
- Imports participants from CSV, XLS, XLSX, or XLSM with validation, preview, and idempotent confirmation.
- Accepts event images through files, folders, ZIP archives, and upload batches; production browser uploads use presigned private S3 requests.
- Runs RetinaFace detection and AdaFace recognition asynchronously, recording face details and model versions in the database.
- Applies similarity, face-quality, and runner-up checks before admitting photographs to a participant's gallery.
- Shows private thumbnails and originals, builds asynchronous ZIP exports, and queues gallery email after processing and approval.
- Removes event media and derived records on deletion or scheduled retention, while tracking usage and audit events.
flowchart TB
Admin["Administrators and Staff"] --> Web
Participant["Participants"] --> Web
Web["React application / NGINX"] --> API["FastAPI API"]
API --> DB[("PostgreSQL + pgvector")]
API --> Redis[("Redis: rate limits and health cache")]
API --> Outbox["Transactional outbox"]
Outbox --> Kafka["Kafka"]
Kafka --> Worker["Background worker"]
DB -->|Durable job fallback| Worker
Worker --> ML["RetinaFace + AdaFace inference"]
API -->|New selfie only| ML
Worker --> DB
API --> Storage[("Private media storage")]
Worker --> Storage
Web -->|Presigned production uploads| Storage
Worker --> Email["SES / Resend / local outbox"]
PostgreSQL is the source of truth for users, events, jobs, face identities, matches, and delivery state. Redis is used for rate limiting and health caching. Kafka transports processing work; a persisted PostgreSQL queue lets workers recover queued work when Kafka is unavailable. Original images, selfies, thumbnails, and gallery ZIPs live in private object storage, rather than inside database rows.
| Path | Responsibility |
|---|---|
webapp/ |
React/Vite dashboards and participant enrollment/gallery pages |
backend/app/ |
FastAPI routes, authorization, SQLAlchemy models, storage/email adapters, matching, and workers |
backend/alembic/ |
Versioned PostgreSQL schema migrations |
backend/tests/ |
Backend security, workflow, storage, and face-index regression tests |
face-processing/service/ |
Gunicorn / ONNX Runtime inference service, CPU/GPU images, and warmup fixture |
face-processing/models/ |
ONNX model locations and integrity manifest |
face-processing/service/app.py |
Inference service implementation |
tools/ |
Model verification, local test runner, and live acceptance checks |
deploy/ |
NGINX configuration, cloud environment contract, and AWS infrastructure/publishing scripts |
docs/ |
Product requirements, implementation map, validation evidence, and cost estimate |
compose.local.yml |
Self-contained local deployment |
compose.cloud.yml |
Application containers connected to managed production dependencies |
compose.gpu.yml |
NVIDIA device reservation for a cloud GPU inference image |
The original /api dashboard endpoints and additive /api/v2 endpoints coexist. V2 covers rotating sessions, import preview, upload batches, processing, enrollment, gallery exports, and delivery. Authorization and tenant checks are enforced by the API.
The AWS cost-estimate workbook is the single detailed planning snapshot. It contains illustrative inputs, rather than live pricing. Regenerate that workbook with python tools/create_cost_report.py after reviewing its assumptions; the generator no longer creates additional export copies.
Use short, descriptive filenames. Python modules and automation scripts in tools/ use snake_case; web helpers and hooks use camelCase; React components use PascalCase; documentation uses kebab-case. Keep standard entrypoints such as README.md, Dockerfile, and app.py. Compose files use compose.<environment>.yml, with compose.gpu.yml as the GPU override. Database migrations keep their revision identifiers.
Model artifacts use retinaface.onnx and adaface.onnx. The checksum manifest identifies the exact model bytes. Model-version settings and stored records retain the architecture and training variant, such as retinaface-r50 and adaface-ir101-ms1mv2, so embedding compatibility remains explicit.
sequenceDiagram
actor Admin as Organization Admin
participant API as FastAPI
participant Worker as Background Worker
participant ML as Face Models
participant DB as PostgreSQL
actor Attendee as Participant
Admin->>API: Upload event photos and complete batch
API->>DB: Store photo metadata and processing jobs
Worker->>ML: Detect and embed each unprocessed photo
ML-->>Worker: Faces, embeddings, landmarks, and quality
Worker->>DB: Persist detections, unique face IDs, and photo links
Worker->>DB: Match existing compatible enrollments
Attendee->>API: Consent and submit a new selfie
API->>ML: Detect and embed this selfie
ML-->>API: Participant embedding
API->>DB: Save enrollment and compare with stored event face index
DB-->>API: Persisted eligible photo matches
API-->>Attendee: Private matched-photo gallery
Attendee->>API: Reopen gallery
API->>DB: Read saved matches and authorize media
API-->>Attendee: Gallery without inference
| Record | Stored information |
|---|---|
photos |
Event/tenant ownership, object keys, checksums, sizes, and processing status |
face_detections |
Detection UUID, photo/unique-face references, bounding box, landmarks, face dimensions, quality class, confidence, embedding, and detector/embedder versions |
unique_faces |
Event-scoped identity UUID, centroid/vector and accumulation state, occurrence count, model version, and timestamps |
unique_face_photos |
Unique face-to-photo mapping and detection count, with duplicate mappings prevented |
face_enrollments |
Participant selfie object reference, embedding/vector, quality, model version, validity, and expiry |
face_matches |
Per-detection participant assignment, similarity, runner-up score, margin, decision state, and threshold/model provenance |
erDiagram
ORGANIZATIONS ||--o{ EVENTS : own
EVENTS ||--o{ PHOTOS : contain
EVENTS ||--o{ UNIQUE_FACES : index
EVENTS ||--o{ PARTICIPANTS : include
PARTICIPANTS ||--o| FACE_ENROLLMENTS : submit
PHOTOS ||--o{ FACE_DETECTIONS : produce
UNIQUE_FACES o|--o{ FACE_DETECTIONS : group
UNIQUE_FACES ||--o{ UNIQUE_FACE_PHOTOS : link
PHOTOS ||--o{ UNIQUE_FACE_PHOTOS : appear_in
FACE_DETECTIONS ||--o| FACE_MATCHES : receive
PARTICIPANTS o|--o{ FACE_MATCHES : identify
A unique face represents an inferred cluster within one event and embedding-model version. It is not a verified civil identity or an identifier shared across organizations. Repeated appearances can share that UUID; rejected detections may have no cluster. Clustering is an aid to retrieval, and gallery eligibility still depends on each detection's matching policy.
Successful processing results are reused. Explicit retries or reprocessing can run inference again, and rebuilding an index may change cluster membership or IDs. New uploads extend the event index. Selfie replacement updates enrollment and affected database matches. Incompatible model versions and expired enrollments are excluded; a model upgrade requires deliberate reprocessing and re-enrollment instead of comparing incompatible vectors.
Defaults use a cosine similarity cutoff of 0.86, a runner-up margin of 0.10, and an additional 0.05 cutoff for low-resolution faces. UNIQUE_FACE_CLUSTER_THRESHOLD=0.75 governs clustering separately. These scores are not identity probabilities; calibrate them with representative event data before launch.
Install Docker Engine with the Compose plugin, Bash, OpenSSL, and standard GNU shell utilities. The local stack runs PostgreSQL/pgvector, Redis, Kafka, API, worker, inference, and NGINX. Node.js 22 and Python 3.12 are needed for checks outside containers.
Supply the two ONNX artifacts at these exact paths:
face-processing/models/detection/retinaface.onnx
face-processing/models/recognition/adaface.onnx
For an existing checkout, rename retinaface-r50.onnx to retinaface.onnx in the detection directory and adaface-ir101-ms1mv2.onnx to adaface.onnx in the recognition directory. This changes filenames only; keep the same model bytes and model-version settings.
The model files are not downloaded automatically. They must match the repository manifest and be readable by inference container UID 10001.
./tools/verify_models.sh
./start.shThe first start creates a private .env with randomly generated database, JWT, administrator, webhook, and verification credentials. Open http://127.0.0.1:8080 and sign in with FDX_SUPER_ADMIN_EMAIL and FDX_SUPER_ADMIN_PASSWORD from that file. Keep .env private. If an existing .env predates a newly required setting, add it from .env.example; the launcher does not overwrite existing credentials.
Only the web port is published, bound to loopback. API, inference, database, Redis, and Kafka remain on the Docker network. Local email is recorded in the application's outbox; the default does not send external messages.
docker compose --env-file .env -f compose.local.yml ps
docker compose --env-file .env -f compose.local.yml logs --tail=100 api worker ml
./stop.shStopping preserves Docker volumes. Keep those volumes when upgrading or restarting. Deleting volumes also deletes local database and media storage.
- A Super Admin creates an organization and invites an Organization Admin.
- The Organization Admin creates an event, imports participants, and sends enrollment invitations.
- Event photographs are uploaded and processed. Participants can enroll before or after photo processing.
- A participant consents and submits a clear selfie; FDX matches it against the persisted event face index and displays eligible photos.
- The dashboard shows uploaded/processed counts, failures, matching results, and email approval state.
- Once all uploads and photos are complete, the Organization Admin approves gallery email, or the event's enabled automatic-send policy queues it.
- Participants revisit their private links or download individual images and asynchronous ZIP exports until the links or event expire.
Events default to manual email approval. Starting another upload or processing attempt clears approval and pauses queued gallery email. Failed or cancelled photos require a successful retry before delivery. Repeated approval does not enqueue duplicate messages; sending a previously sent gallery again requires explicit resend. Enrollment and team invitations can be sent before processing finishes.
Use .env.example for local deployment and deploy/cloud.env.example for the cloud contract. Compose validates required settings before startup. Values below describe the supplied Compose configuration.
| Settings | Purpose / default |
|---|---|
POSTGRES_PASSWORD, JWT_SECRET, FDX_SUPER_ADMIN_PASSWORD, EMAIL_WEBHOOK_SECRET |
Required secrets; generated locally, supplied from a secret manager in production |
FDX_WEB_PORT |
Local loopback web port; 8080 |
DATABASE_URL, REDIS_URL, KAFKA_BOOTSTRAP_SERVERS |
Required managed dependency addresses in cloud deployments |
API_IMAGE, WEB_IMAGE, ML_IMAGE |
Required cloud image references; prefer immutable release digests |
FRONTEND_URL, S3_BUCKET, AWS_REGION, EMAIL_FROM |
Public HTTPS origin, private media bucket, region, and verified production sender |
TRUSTED_PROXY_CIDRS |
Cloud NGINX ingress proxies allowed to supply client IP/protocol; empty by default |
FORWARDED_ALLOW_IPS |
Uvicorn forwarding trust; Compose uses * within its private API network |
ACCESS_TOKEN_MINUTES, REFRESH_TOKEN_DAYS |
Access token lifetime 15 minutes; rotating refresh session lifetime 7 days |
ENROLLMENT_TOKEN_DAYS, GALLERY_TOKEN_DAYS |
Enrollment/gallery link lifetimes; both 7 days in Compose |
MAX_UPLOAD_BYTES |
Aggregate upload allowance; 100 GiB |
MAX_MEDIA_FILE_BYTES, MAX_ENROLLMENT_BYTES |
Individual media 100 MiB; selfie 15 MiB |
MAX_IMAGE_PIXELS |
Decode limit; 100,000,000 pixels |
DB_POOL_SIZE, DB_MAX_OVERFLOW |
Per-process connection budget; 5 + 5 |
WORKER_CONCURRENCY, ML_INFERENCE_THREADS |
Photo jobs per worker and inference threads; both 4 |
MATCH_AUTO_THRESHOLD, MATCH_RUNNER_UP_MARGIN |
Gallery matching policy; 0.86, 0.10 |
UNIQUE_FACE_CLUSTER_THRESHOLD |
Similarity required to group compatible appearances; 0.75 |
FDX_DETECTOR_MODEL_VERSION, FDX_EMBEDDER_MODEL_VERSION, THRESHOLD_PROFILE_VERSION |
Persisted provenance and embedding compatibility |
CONSENT_POLICY_VERSION |
Version attached to recorded participant consent |
The NGINX request body limit is 10 GiB; individual media and application quota checks also apply. Browser S3 uploads bypass that proxy body limit. Size budgets are limits rather than capacity promises; tune concurrency, temporary storage, and database connections against measured workloads.
From the repository root:
python3.12 -m venv .venv
. .venv/bin/activate
python -m pip install -r backend/requirements-dev.txt
npm --prefix webapp ci
make format
make format-check
make lint
make test
make buildInstall ShellCheck for shell linting and jq for deployment tests. make format applies Prettier to supported JavaScript/JSX, CSS, JSON, Markdown, YAML, and HTML files and Ruff to Python; binary assets and generated directories are excluded. Dockerfiles and shell scripts are checked using their applicable tools rather than passed to Prettier.
make test runs frontend tests, backend tests against an isolated Docker PostgreSQL/pgvector database, and offline deployment-publication regressions. make check combines the routine formatting, lint, test, and frontend-build checks. make audit checks npm and Python dependencies. CI also validates Compose, lints CloudFormation, checks shell syntax, runs migrations, and builds application container images.
With model artifacts present, run the optional real-model Docker smoke checks:
make test-mlThis verifies model checksums, builds the CPU inference image, and checks readiness, normalized 512-dimensional embeddings, and input validation.
make test-proxy runs Docker integration checks for trusted proxy client addresses, HTTPS forwarding, and rejection of spoofed forwarding headers.
For integration checks against a running disposable local/staging deployment:
node tools/verify_platform.mjs
FDX_VERIFY_FACE_IMAGE=/path/to/clear-face.jpg node tools/verify_platform.mjs
FDX_VERIFY_FACE_IMAGE=/path/to/clear-face.jpg node tools/verify_workflow.mjsThe live verifiers create and mutate test records, exercise tenant/staff authorization, uploads, enrollment, processing, galleries, delivery, and deletion. They read credentials from the private .env. V2 requires a real face image. FDX_VERIFY_XLS=/path/to/participants.xls adds legacy Excel coverage to the platform verifier. Unit tests do not substitute for this real-model integration path.
The API entrypoint runs alembic upgrade head before serving traffic. Migration and bootstrap advisory locks serialize competing API starts. Workers set RUN_MIGRATIONS=false and wait for API readiness. Schema history lives in backend/alembic/versions/; tables are not implicitly created in production.
Inspect a running local database's migration state:
docker compose --env-file .env -f compose.local.yml exec api alembic currentBefore upgrading, back up database and media, review pending migrations, and run the new build and acceptance flow in staging. Face-index migration 20260911_12 records detection embedding-model versions and accurate centroid accumulation; existing cluster UUIDs remain in place. Older rows with unresolvable provenance cannot safely be assumed compatible with a newly installed model.
Use additive migrations for releases that run old and new containers concurrently. Rolling the image back does not roll the database schema back. Restore a tested backup or apply a reviewed corrective migration if data compatibility prevents an image-only rollback.
deploy/README.md describes the runtime contract; deploy/aws/README.md covers the AWS deployment. The template provisions a two-AZ VPC, HTTPS ALB, EC2 Auto Scaling, RDS PostgreSQL, ElastiCache Redis, MSK Kafka, ECR, private/versioned S3, SES identity, IAM, Secrets Manager, SSM, and scheduled retention.
For another deployment environment, populate a private copy of deploy/cloud.env.example, mount readable model artifacts at /opt/fdx/face-processing/models, then start:
docker compose --env-file /secure/path/fdx-production.env -f compose.cloud.yml config --quiet
docker compose --env-file /secure/path/fdx-production.env -f compose.cloud.yml up -dUse a private network with HTTPS ingress to cloud port 8080. Production Compose requires managed services, private S3 storage, SES, and production secrets. It does not provision them. Container logs rotate locally, but durable centralized log collection and alerts still need configuration.
NGINX ignores incoming client IP/protocol headers unless the connection originates from an explicitly configured TRUSTED_PROXY_CIDRS address. The AWS bootstrap trusts its ALB subnets, and the ALB appends the real client address. NGINX discards the incoming forwarding chain and supplies one resolved address to the API, so rate limits and audit records use the client instead of a shared proxy. See the proxy trust contract before changing ingress or publishing API ports.
To use an NVIDIA host with compatible drivers and NVIDIA Container Toolkit, build and publish the separate GPU image, set ML_IMAGE to that image, and include the GPU overlay:
docker build -f face-processing/service/Dockerfile.gpu -t fdx-ml:gpu .
docker compose --env-file /secure/path/fdx-production.env \
-f compose.cloud.yml -f compose.gpu.yml up -dThe overlay reserves one NVIDIA GPU and selects CUDA. The regular image contains CPU ONNX Runtime. The supplied AWS launcher uses CPU hosts/images by default; its publisher does not provision a GPU host or automatically enable this overlay.
| Signal | Meaning |
|---|---|
/health/live |
API process is responding; reachable within the application network |
/health/ready |
API can query its database; also routed through NGINX for the load balancer |
/health/dependencies |
Dependency diagnostics; available directly on the API |
/metrics |
In-process API request/error/latency counters in Prometheus text format |
| Worker jobs and logs | Processing progress, retries, failure states, and queue recovery |
| Email outbox and deliveries | Provider acceptance, retries, and gallery approval state |
Readiness alone does not prove ML, Kafka, email, or storage functionality. Watch queue age, repeated retries, database connections, host memory/disk, storage growth, and inference health in addition to HTTP probes. API metrics reset on process restart and must be collected per replica.
Create a local database backup without displaying credentials:
mkdir -p backups
chmod 700 backups
umask 077
docker compose --env-file .env -f compose.local.yml exec -T postgres \
pg_dump -U fdx -d fdx -Fc > backups/fdx.dumpAlso back up the object_storage volume with a consistent snapshot while uploads, processing, and deletion are paused. Database backups alone do not contain the images. For AWS, the template retains RDS backups for 14 days and enables S3 versioning; regularly test restoring both metadata and corresponding object versions in an isolated environment.
Application deletion removes active object keys and derived database records. Versioned S3 storage can retain older object versions until its configured 30-day noncurrent-version expiry; backups have their own retention. Align those settings with the declared deletion policy. The independent S3 lifecycle expires event objects after DeleteAfterDays (default 365), so event retention must not promise availability beyond that ceiling.
Before promoting a release, run the real-model acceptance flow with private S3, managed dependencies, HTTPS, and outbound email; test backup restoration; calibrate matching against the intended event population; and measure target event sizes and concurrent uploads. The repository supplies production deployment components and automated checks, but source checks cannot establish the availability, model accuracy, delivery success, or capacity of an untested live installation.