Skip to content

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 key
  • ADMIN_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 endpoints
  • API_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

  1. Network isolation: Node B only talks to Node A via HTTPS (mTLS in production, API key for POC)
  2. Data residency: Firm PII stays in Node B container. VRS data stays in Node A.
  3. Derived features only: One-way transforms cross the wire (soundex, year, postcode area, SHA-256)
  4. Permission gating: Node A enforces Type A/B output classification. Firm cannot bypass.
  5. Purpose consent: VRS records have permitted_purposes. Matches suppressed if purpose not consented.
  6. Audit trail: FusionRun record created at Node A — immutable, Article 12 compliant.
  7. No raw results stored at Node B: Node B stores gated output only.
  8. 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: true on 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