VRS REST API v2.0 — Two-Container Federated Deployment
Status & scope
- Stage: Draft
- Author: Chris
- Date: 2026-03-13
1. Overview
The VRS API v2.0 deploys as two containers proving the federated compliance model:
- Node A (Axonis-hosted): Hub service — VRS data from ES, scoring, gating, audit
- Node B (firm-hosted): Thin container — local normalization, blocking, feature derivation
Raw PII never crosses the network boundary. Only derived features (soundex codes, year extracts, postcode areas, SHA-256 hashes) and gated results traverse the wire.
1.1 What Crosses the Wire
| Phase | Node B → Node A | Node A → Node B |
|---|---|---|
| 1: Bucket Signals | {blocking_key: count} |
{blocking_key: count} + shared bucket list |
| 1-PSI: DH-PSI | Masked integers (H(key)^secret) | Masked + double-masked integers |
| 2: Derived Vectors | {id, soundex, year, postcode_area, sha256...} |
{id, soundex, year, postcode_area, sha256...} |
| 3: Consensus | — | {pair_id, confidence, per_field_scores, match_status, gated_output} |
Never crosses: raw names, full DOBs, full postcodes, phone numbers, emails, VRS vulnerability details (Type A), any firm customer data.
2. Node A API (Axonis-hosted)
Base URL: http://node-a:8000/api/v2
2.1 Authentication
All endpoints require X-API-Key header. Two key types:
- Node key (API_KEY): Used by Node B for phase endpoints
- Admin key (ADMIN_KEY): Used for snapshot refresh
2.2 Endpoints
POST /phase1/exchange
Receive Node B bucket signals, return Node A signals + shared bucket list.
Request:
{
"run_id": "SCR-abc123def456",
"node_id": "node_b",
"bucket_signals": {
"S530:1985": 3,
"J520:1990": 1
}
}
Response (200):
{
"run_id": "SCR-abc123def456",
"node_a_signals": {
"S530:1985": 5,
"J520:1990": 2,
"B620:1978": 1
},
"shared_buckets": ["S530:1985", "J520:1990"]
}
POST /phase1/psi/round1 — PSI Variant
DH-PSI Round 1: Node B sends masked bucket keys. Node A masks its own keys and double-masks Node B's. Neither side learns the other's non-matching keys.
Request:
{
"run_id": "SCR-abc123def456",
"node_id": "node_b",
"masked_elements": [123456789, 987654321, ...]
}
Response (200):
{
"run_id": "SCR-abc123def456",
"node_a_masked": [111222333, 444555666, ...],
"node_b_double_masked": [777888999, 101112131, ...]
}
POST /phase1/psi/round2 — PSI Variant
DH-PSI Round 2: Node B sends double-masked Node A keys + the bucket keys it found in the intersection. Node A verifies and returns the final shared bucket list.
Request:
{
"run_id": "SCR-abc123def456",
"node_id": "node_b",
"node_a_double_masked": [222333444, 555666777, ...],
"node_b_bucket_keys": ["S530:1985", "J520:1990"]
}
Response (200):
{
"run_id": "SCR-abc123def456",
"shared_buckets": ["S530:1985", "J520:1990"],
"psi_intersection_size": 2
}
Privacy guarantee: Masked values are computationally indistinguishable from random. Only elements in the intersection are revealed. Node A never learns Node B's non-matching bucket keys, and vice versa.
POST /phase2/exchange
Receive Node B derived vectors for shared buckets, return Node A vectors + scored results.
Request:
{
"run_id": "SCR-abc123def456",
"node_id": "node_b",
"shared_buckets": ["S530:1985", "J520:1990"],
"derived_vectors": [
{
"local_id": "FIRM-001",
"full_name": "S530",
"date_of_birth": "1985",
"postcode": "SW1A",
"email": "a1b2c3..."
}
]
}
Response (200):
{
"run_id": "SCR-abc123def456",
"node_a_vectors": [
{
"local_id": "VRS-001",
"full_name": "S530",
"date_of_birth": "1985",
"postcode": "SW1",
"email": "d4e5f6..."
}
]
}
POST /phase3/score
Score all candidate pairs, apply gating, return gated results.
Request:
{
"run_id": "SCR-abc123def456",
"node_b_vectors": [
{"local_id": "FIRM-001", "full_name": "S530", "date_of_birth": "1985", "postcode": "SW1A", "email": "a1b2c3..."}
],
"node_a_vectors": [
{"local_id": "VRS-001", "full_name": "S530", "date_of_birth": "1985", "postcode": "SW1", "email": "d4e5f6..."}
],
"threshold": 0.50,
"screening_purpose": "internal_compliance"
}
Response (200):
{
"run_id": "SCR-abc123def456",
"matches": [
{
"customer_id": "FIRM-001",
"vrs_id": "VRS-001",
"match_status": "confirmed",
"confidence": 0.9612,
"matched_fields": ["firstname", "surname", "date_of_birth", "postcode"],
"conflicted_fields": [],
"permission_type": "A",
"vulnerability_codes": ["VC01", "VC03"],
"vulnerability_count": 2,
"decline_credit": "no",
"registration_type": "self"
}
],
"summary": {
"confirmed": 5,
"probable": 2,
"conflict": 0,
"no_match": 18,
"purpose_denied": 1
},
"fusion_run": {
"run_id": "SCR-abc123def456",
"status": "completed",
"started_at": "2026-03-13T10:00:00+00:00",
"completed_at": "2026-03-13T10:00:01+00:00",
"total_candidates": 42,
"total_matches": 7
}
}
GET /health
Kubernetes liveness/readiness probe.
Response (200):
{
"status": "healthy",
"vrs_loaded": true,
"vrs_count": 150,
"version": "2.0.0"
}
POST /admin/snapshot/refresh
Reload VRS data from Elasticsearch. Requires admin API key.
Response (200):
{
"status": "refreshed",
"vrs_count": 150,
"index": "vrs-registry"
}
3. Node B API (Firm-hosted)
Base URL: http://node-b:8001/api/v2
3.1 Authentication
All endpoints require X-API-Key header (firm API key).
3.2 Endpoints
POST /customers/upload
Upload firm customer CSV. Validates required fields per SOW §5.
Request: multipart/form-data with CSV file.
Required CSV columns: firstname, surname, date_of_birth, postcode
ID column (one required): local_id, internal_ref, customer_id, or id
Optional: email, phone, address
Response (200):
{
"status": "uploaded",
"record_count": 25,
"malformed_count": 0,
"fields_detected": ["local_id", "firstname", "surname", "date_of_birth", "postcode", "email"]
}
Response (422):
{
"error": "validation_error",
"detail": "Missing required columns: date_of_birth"
}
POST /screening/run
Trigger three-phase screening against Node A. Synchronous — returns when complete.
Request (optional body):
{
"threshold": 0.50,
"screening_purpose": "internal_compliance",
"use_psi": false
}
Response (200):
{
"run_id": "SCR-abc123def456",
"status": "completed",
"summary": {
"confirmed": 5,
"probable": 2,
"conflict": 0,
"no_match": 18,
"total_screened": 25,
"total_vrs": 150,
"purpose_denied": 1
}
}
GET /screening/{run_id}/status
Poll screening progress.
Response (200):
{
"run_id": "SCR-abc123def456",
"status": "completed",
"phase": "done"
}
GET /screening/{run_id}/results
Get full gated match results.
Response (200):
{
"run_id": "SCR-abc123def456",
"matches": [
{
"customer_id": "FIRM-001",
"vrs_id": "VRS-001",
"match_status": "confirmed",
"confidence": 0.9612,
"matched_fields": ["firstname", "surname", "date_of_birth", "postcode"],
"conflicted_fields": [],
"permission_type": "A",
"vulnerability_codes": ["VC01", "VC03"],
"vulnerability_count": 2,
"decline_credit": "no",
"registration_type": "self",
"vulnerability_details": []
}
]
}
GET /screening/{run_id}/summary
Aggregate counts only.
Response (200):
{
"run_id": "SCR-abc123def456",
"confirmed": 5,
"probable": 2,
"conflict": 0,
"no_match": 18,
"total_screened": 25,
"total_vrs": 150,
"purpose_denied": 1,
"elapsed_seconds": 0.342
}
GET /screening/{run_id}/audit
FusionRun audit record for compliance.
Response (200):
{
"run_id": "SCR-abc123def456",
"lens_id": "vrs_alerts_v1",
"lens_version": "1.0.0",
"execution_mode": "ad_hoc",
"started_at": "2026-03-13T10:00:00+00:00",
"completed_at": "2026-03-13T10:00:01+00:00",
"status": "completed",
"threshold": 0.50,
"null_penalty": 0.1,
"blocking_strategy": "hash",
"total_candidates": 42,
"total_matches": 7,
"phase1_complete": true,
"phase2_complete": true,
"phase3_complete": true
}
GET /screening/{run_id}/export
Download results as CSV.
Response (200): text/csv attachment with columns:
customer_id, vrs_id, match_status, confidence, matched_fields, vulnerability_codes, vulnerability_count, decline_credit, registration_type, permission_type
GET /health
Response (200):
{
"status": "healthy",
"customers_loaded": true,
"customer_count": 25,
"node_a_reachable": true,
"version": "2.0.0"
}
4. Error Handling
All errors return JSON with error and detail fields:
| HTTP Code | Error | When |
|---|---|---|
| 401 | unauthorized |
Missing or invalid API key |
| 404 | not_found |
Unknown run_id |
| 409 | no_customers |
Screening run before customer upload |
| 422 | validation_error |
Bad CSV, missing fields |
| 502 | node_a_unreachable |
Node B cannot reach Node A |
| 500 | internal_error |
Unexpected failure |
5. Container Specification
5.1 Node A
- Base image:
python:3.10-slim - Port: 8000
- UID: 1001 (non-root)
- Dependencies: fastapi, uvicorn, pydantic, pydantic-settings, elasticsearch, pyyaml, jellyfish, rapidfuzz, jsonschema
- Environment:
ES_URL— Elasticsearch URL (default:http://localhost:9200)ES_INDEX— VRS index name (default:vrs-registry)API_KEY— Node-to-node API keyADMIN_KEY— Admin operations API key- Startup: Load VRS from ES into memory on lifespan startup
- Compute: Full parallax engine — normalize, block, derive, score, gate, audit
5.2 Node B
- Base image:
python:3.10-slim - Port: 8001
- UID: 1001 (non-root)
- Dependencies: fastapi, uvicorn, pydantic, pydantic-settings, httpx, pyyaml, jellyfish, rapidfuzz, jsonschema
- Environment:
NODE_A_URL— Node A base URL (default:http://localhost:8000)NODE_A_KEY— API key for Node A phase endpointsAPI_KEY— Firm-facing API key- Startup: Empty — waits for customer upload
- Compute: Local parallax subset — normalize, block, derive features only. NO scoring, NO VRS data.
6. Security Model
- Network isolation: Node B only talks to Node A via HTTPS (mTLS in production, API key for POC)
- Data residency: Firm PII stays in Node B container. VRS data stays in Node A.
- Derived features only: One-way transforms cross the wire (soundex, year, postcode area, SHA-256)
- Permission gating: Node A enforces Type A/B output classification. Firm cannot bypass.
- Purpose consent: VRS records have
permitted_purposes. Matches suppressed if purpose not consented. - Audit trail: FusionRun record created at Node A — immutable, Article 12 compliant.
- No raw results stored at Node B: Node B stores gated output only.
- PSI option for Phase 1: DH-PSI (RFC 3526 Group 14, 2048-bit MODP) ensures neither side learns the other's non-matching bucket keys. Enabled via
use_psi: trueon screening run request.
7. Reused Parallax Modules
| Module | Used by | Purpose |
|---|---|---|
three_phase.py |
Node A | Phase 1/2/3 protocol functions |
hub.py |
Node A | Orchestration, gating, conflict detection |
derived_features.py |
Both | One-way feature derivation |
normalizer.py |
Both | Record normalization + alias expansion |
blocker.py |
Both | Blocking key generation |
scorer.py |
Node A | Pair scoring with derived metrics |
models/fusion_run.py |
Node A | Audit trail (Article 12) |
models/consent.py |
Node A | GDPR Article 7 consent enforcement |
adapters/es_adapter.py |
Node A | VRS data from Elasticsearch |
pipeline.py |
Node A | FusionMatch, match function building |
psi.py |
Both | DH-PSI bucket discovery (optional Phase 1 variant) |
8. Production Migration Path
| POC Component | Production Target |
|---|---|
Node A hub_service.py |
titan FusionNode (TASK-T01) |
Node A vrs_store.py |
titan ES persistence (TASK-T02) |
Node B node_a_client.py |
titan wire adapter (component.parallax.wire-protocol-adapters mTLS/CDG) |
| Node A phase endpoints | fedai-rest API surface |
| API key auth | mTLS with client certificates |
| In-memory stores | Redis / ES persistence |
| Synchronous flow | Async with status polling |
Depends on: component.parallax.derived-features, component.parallax.three-phase-protocol
Realizes: product.fusion