# Compliance Plugin (aissurance.eu) The compliance plugin turns the NOMYO Router from a pure traffic proxy into an **AI compliance agent**. When enabled, it periodically snapshots your AI-infrastructure landscape — model inventory, endpoint registry, and aggregate telemetry — and sends signed, structured evidence to [aissurance.eu](https://www.aissurance.eu), the EU AI Actf compliance platform. The plugin runs **in-process** inside the Router; it is not a separate service. It only reads infrastructure metadata the Router already maintains for routing. **No prompt or completion content is ever read, stored, or transmitted.** > **Non-critical by design.** Any failure inside the plugin is logged and > contained — it never affects request routing. If the upstream is unreachable, > the Router keeps proxying and buffers evidence for later. --- ## How it works ``` Router state (models, endpoints, usage, cache, token DB) │ read-only snapshot, every polling_interval ▼ Snapshot Collector → Payload Builder → HMAC-SHA256 Signer │ ▼ HTTPS POST (outbound only) ──────────────► aissurance.eu │ 202 Accepted → done /api/v1/discovery/receive │ network error / 5xx → encrypt + buffer to disk, retry next cycle ▼ Encrypted offline buffer (replayed oldest-first on reconnect) ``` **All communication is outbound from the Router.** aissurance.eu never initiates contact, so the Router can run behind NAT, firewalls, or strict egress policies. --- ## What is and isn't collected This is the most important part to understand before enabling the plugin. ### Collected (infrastructure metadata only) - **Model inventory** — name, version, quantization, weight size, serving endpoint - **Endpoint registry** — URL, type, health status, concurrency, TLS, auth method - **Aggregate telemetry** — active connections, uptime, global cache hit rate, 24h token totals ### Never collected - Individual request payloads (prompt / completion content) - User identity or auth tokens from proxied requests - Conversation content passing through the proxy - End-user client IP addresses ### Emitted as `null` (not yet instrumented) The Router does not currently measure the following, so the plugin emits `null` rather than fabricating values. They are candidates for a future instrumentation phase: | Field | Where | |---|---| | `request_count_24h`, `error_count_24h` | per model | | `avg_latency_ms`, `p99_latency_ms` | per model | | `cache_hit_rate`, `avg_input_tokens`, `avg_output_tokens` | per model | | `total_requests_24h`, `error_rate` | telemetry | > **Note on byte fields.** The Router tracks **tokens**, not raw bytes. > `total_bytes_in_24h` / `total_bytes_out_24h` therefore carry 24h **token** > counts as the closest available signal. --- ## Quick start 1. Obtain your `AISSURANCE_KEY` (and optionally `AISSURANCE_TENANT_ID`) from aissurance.eu during tenant signup. This is **distinct** from the Router's own `nomyo-router-api-key` — see [Dual-key model](#dual-key-model). 2. Export the secret (never commit it): ```bash export AISSURANCE_KEY="aiss_live_…" export AISSURANCE_TENANT_ID="550e8400-e29b-41d4-a716-446655440000" # optional ``` 3. Add the `compliance` block to `config.yaml`: ```yaml compliance: enabled: true server_url: "https://www.aissurance.eu/api/v1/discovery/receive" api_key: "${AISSURANCE_KEY}" tenant_id: "${AISSURANCE_TENANT_ID}" polling_interval: 300 ``` 4. Restart the Router. On startup you should see structured logs: ```json {"event":"plugin_started","router_id":"router-prod-001","polling_interval":300,"buffered":0} ``` --- ## Configuration options All options live under the `compliance:` block in `config.yaml`. | Option | Type | Default | Description | |---|---|---|---| | `enabled` | `bool` | `false` | Master switch. All other options are ignored when `false`. | | `server_url` | `str` | `https://www.aissurance.eu/api/v1/discovery/receive` | Discovery receive URL. Must be HTTPS. Health and config-report URLs are derived from it. | | `api_key` | `str` | `${AISSURANCE_KEY}` | Tenant secret. Used as the Bearer token and as the HMAC signing key. | | `tenant_id` | `str` | `${AISSURANCE_TENANT_ID}` | Tenant UUID. Optional if embedded in the key. | | `router_id` | `str` | machine hostname | Unique identifier for this Router instance. Multiple instances per tenant are allowed. | | `polling_interval` | `int` | `300` | Seconds between discovery snapshots. Minimum `60`. | | `health_interval` | `int` | `3600` | Seconds between health / keepalive calls. | | `batch_size` | `int` | `50` | Max models per payload. On `413` the payload is auto-split into batches. | | `max_retry_attempts` | `int` | `10` | In-cycle send retries before the payload is buffered to disk. | | `retry_backoff_base` | `int` | `2` | Exponential backoff base in seconds (`base ** attempt`, capped at 60s). | | `buffer_dir` | `str` | `./compliance-buffer` | Directory for encrypted offline retry files. | | `max_buffer_payloads` | `int` | `100` | Buffer cap. Oldest file is dropped first when exceeded. | ### `polling_interval` How often a snapshot is collected and sent. The server enforces a maximum of one payload per `polling_interval` per tenant; sending faster yields `429`. Values below `60` are rejected at startup. ### `batch_size` Caps how many models a single payload carries. If the upstream replies `413 Payload Too Large`, the plugin splits the model list into `batch_size`-sized chunks, re-signs each chunk, and sends them individually. ### `buffer_dir` and `max_buffer_payloads` When the upstream is unreachable, each signed payload is encrypted and written to `buffer_dir`. The directory is bounded by `max_buffer_payloads`; once full, the oldest file is dropped before a new one is written. Buffered payloads are replayed oldest-first on the next successful cycle. Add `buffer_dir` to your backup exclusions — files are encrypted but transient. --- ## Environment variables | Variable | Required | Description | |---|---|---| | `AISSURANCE_KEY` | Yes | Tenant secret issued at signup. Referenced by `api_key: "${AISSURANCE_KEY}"`. | | `AISSURANCE_HMAC_SECRET` | No | Per-tenant HMAC secret override. When set, it signs payloads instead of `AISSURANCE_KEY`. | | `AISSURANCE_TENANT_ID` | No | Tenant UUID, if not embedded in the key. | | `AISSURANCE_VERIFY_TLS` | No | Set to `"0"` to allow self-signed certs (development only — logged as a warning). | Values referenced as `${VAR}` in `config.yaml` are expanded at load time. If a referenced variable is unset, the corresponding environment-variable default above takes over. --- ## Outbound endpoints All three are Router-initiated POSTs. URLs are derived from `server_url` by replacing the trailing `/receive` path. | Endpoint | When called | Purpose | |---|---|---| | `POST …/discovery/receive` | every `polling_interval` | Send the signed discovery payload. | | `POST …/discovery/health/{tenant_id}` | on startup + every `health_interval` | Connectivity check / keepalive / config sync. | | `POST …/discovery/config/report` | on startup handshake | Report the active compliance config for acknowledgment. | Every request carries: ``` Authorization: Bearer ${AISSURANCE_KEY} X-Router-Version: X-Router-Id: Content-Type: application/json ``` ### Response handling | Status | Meaning | Plugin action | |---|---|---| | `202` / `200` | Accepted | Mark sent; replay any buffered payloads. | | `401` / `403` | Invalid key / blacklisted | **Stop reporting**, clear the buffer, log `auth_failed`. | | `429` | Rate limited | Drop this snapshot (the next one is just as fresh). | | `413` | Too large | Split by `batch_size`, re-sign, resend. | | `5xx` / network error | Server / connectivity failure | Retry with backoff, then encrypt + buffer to disk. | --- ## Payload structure ```json { "tenant_id": "550e8400-e29b-41d4-a716-446655440000", "router_id": "router-prod-001", "router_version": "0.9", "timestamp": "2026-06-10T10:30:00Z", "models": [ { "name": "mistral-7b-instruct", "version": "latest", "quantization": "Q4_K_M", "size_gb": 4.1, "endpoint": "http://192.168.0.50:11434", "last_used": "2026-06-10T10:28:14Z", "request_count_24h": null, "avg_latency_ms": null, "p99_latency_ms": null, "error_count_24h": null, "cache_hit_rate": null, "avg_input_tokens": null, "avg_output_tokens": null } ], "endpoints": [ { "url": "http://192.168.0.50:11434", "type": "ollama", "status": "healthy", "concurrent_connections": 2, "max_concurrent_connections": 4, "tls_enabled": false, "auth_method": "none" } ], "telemetry": { "total_requests_24h": null, "total_cache_hits": 1547, "cache_hit_rate": 0.634, "error_rate": null, "total_bytes_in_24h": 9120344, "total_bytes_out_24h": 14882910, "active_connections": 2, "uptime_hours": 72.4 }, "hmac_signature": "sha256=abcdef…" } ``` `endpoint.type` is best-effort: `ollama`, `llama_cpp`, `vllm`, `openai`, `gemini`, `anthropic`, `cohere`, `cerebras`, `inception labs`, or `other`. `endpoint.status` maps the Router's health probe onto `healthy` / `unhealthy`. --- ## Security ### Dual-key model | Credential | Protects | Managed by | |---|---|---| | `nomyo-router-api-key` | The Router's own API / dashboard | Router operator | | `AISSURANCE_KEY` | The upstream reporting channel | aissurance.eu (issued at signup) | These are independent. See [Router API key usage](configuration.md#using-the-router-api-key) for the former. ### Payload signing Every payload is HMAC-SHA256 signed. The signature covers all fields **except** `hmac_signature`, over a canonical `sort_keys` serialization, so any tampering with the body invalidates it. The signing key is `AISSURANCE_HMAC_SECRET` if set, otherwise `AISSURANCE_KEY`. ### Buffer encryption Offline buffer files are encrypted at rest with a key derived from your `AISSURANCE_KEY` via HKDF-SHA256 (Fernet / AES-128-CBC + HMAC). A leaked buffer file is useless without the tenant secret. Corrupt or wrong-key files are dropped on read. ### Transport TLS is required (`server_url` must be HTTPS). `AISSURANCE_VERIFY_TLS=0` disables certificate verification for development only and is logged as a warning. --- ## Observability The plugin writes structured JSON logs to stderr (one object per line): ```json {"timestamp":"2026-06-10T10:30:00Z","level":"info","module":"aissurance","event":"payload_sent","tenant_id":"abc123","router_id":"router-prod-001","models_count":12,"response_code":202,"latency_ms":245} ``` Event types: | Event | Meaning | |---|---| | `plugin_started` / `plugin_stopped` | Lifecycle. `plugin_stopped` reports buffered + successful counts. | | `plugin_disabled` | Startup validation failed (e.g. missing key, non-HTTPS URL). Reasons included. | | `payload_sent` | A discovery payload (or a replayed buffer file) was accepted. | | `payload_buffered` | Upstream unreachable; payload encrypted to disk. | | `payload_retry` | A transient failure is being retried with backoff. | | `payload_rate_limited` | Server returned `429`; snapshot dropped. | | `auth_failed` | Key rejected (`401`/`403`); reporting halted, buffer cleared. **Operator action required.** | | `config_reported` | Config handshake completed; any server suggestion included. | | `snapshot_too_slow` | Collection exceeded its 10s budget; cycle skipped. | | `tenant_id_missing` | No tenant id resolved; payloads will carry `tenant_id: null`. | --- ## Lifecycle **Startup** — validate config (HTTPS URL, key present, `polling_interval ≥ 60`); on failure the plugin logs `plugin_disabled` and stays dormant without affecting the Router. On success it performs a health + config-report handshake, then starts the snapshot and health loops. **Runtime** — the first snapshot is emitted immediately so evidence appears without waiting a full interval, then the plugin settles into `polling_interval`. Snapshot collection runs under a 10s budget and reads from shared Router state without blocking request routing. **Shutdown** — the loops stop, the HTTP client closes, and a `plugin_stopped` log records how many payloads were buffered vs. sent. The Router continues and completes its own shutdown normally. > **Config changes require a restart.** Like the rest of the Router config, the > `compliance` block is read at startup. Hot-reload of the compliance settings is > not yet supported. --- ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | `plugin_disabled` at startup | Missing key, non-HTTPS `server_url`, or `polling_interval < 60` | Check the `reasons` array in the log line. | | `auth_failed`, reporting stops | `AISSURANCE_KEY` invalid/expired/blacklisted | Re-provision the key on aissurance.eu and restart. | | Buffer files accumulating | Upstream unreachable | Confirm egress to aissurance.eu over HTTPS; check firewall/NAT. | | `tenant_id` is `null` in payloads | No `AISSURANCE_TENANT_ID` / `compliance.tenant_id` | Set it, unless your key embeds the tenant. | | `snapshot_too_slow` warnings | Slow/unhealthy backends delaying health probes | Investigate endpoint health; the cycle self-recovers next tick. |