Skip to content

Ludex — trusted-data egress (pull API)

Ludex protects your telemetry behind a validation + drift recovery pipeline before it becomes "trusted data" you can feed into dashboards, analytics, or ML pipelines. The trusted-data egress surface is how your systems pull that already-validated (and recovered) event stream back out — securely, incrementally, and with full provenance on every row.

This page documents Phase 2 of the trusted-data egress plan: the pull API (GET /v1/trusted/events). Phase 3 adds scheduled Parquet exports and per-object signed download URLs — see Trusted-data egress (Parquet exports).

What you get from the pull API

  • Validated telemetry only. Rows appear here only after they have passed deterministic schema validation, or after they have been recovered through a governed schema-drift resolution (replay path). Raw, never-validated events are never served.
  • Provenance on every row. Every response row carries a trust_origin field with one of:
  • validated — the row passed the active schema on the first try.
  • recovered — the row originally failed validation, but a governed recovery (approved schema + replay) later accepted it.
  • Policy-aware filtering. Each (organization, project, environment) scope has a TrustedExportPolicy with a mode:
  • validated_only — recovered rows are excluded.
  • validated_plus_recovered — both origins are served.
  • custom — reserved for future per-field or per-state filters.
  • Keyset pagination. Pages use an opaque cursor over (timestamp, id) so concurrent writes on the server never produce duplicate or skipped rows during a walk.
  • Credential-scoped strictly. Unlike dashboard reads, trusted-egress credentials are narrowed to a single project and a single environment. There is no "read everything I can see" mode in v1 (see Strict credential scoping).

Endpoint

GET /v1/trusted/events

Authentication

Use a Ludex Bearer API key with the read:trusted scope. Authentication is identical to the ingestion and dashboard surfaces:

Authorization: Bearer <your-api-key>

The credential fixes the organization_id, project_id, and environment_id you can read. Requests outside that scope are rejected — see below.

Query parameters

Parameter Type Default Purpose
project_id string credential's project Must equal the credential's project (or be omitted). See Strict credential scoping.
environment_id string credential's environment Must equal the credential's environment (or be omitted).
since ISO-8601 UTC open Inclusive lower bound on timestamp (e.g. 2026-04-20T00:00:00Z).
until ISO-8601 UTC open Exclusive upper bound on timestamp.
event_type string any Filter by ingestion event_type.
ludex_event_type string any Filter by normalised Ludex event type.
source_event_name string any Filter by original source event name.
include_recovered bool policy-governed false narrows a validated_plus_recovered policy to validated rows only. Cannot widen a validated_only policy — policy is authoritative.
limit int (1-5000) 500 Page size ceiling.
cursor opaque string none Pass the next_cursor from the previous response.

Response shape

{
  "status": "ok",
  "message": "Trusted events (Phase 2 pull API)",
  "data": [
    {
      "organization_id": "org_abc",
      "project_id": "proj_xyz",
      "environment_id": "env_prod",
      "event_id": "evt_012",
      "timestamp": "2026-04-20T12:00:00Z",
      "event_type": "score",
      "ludex_event_type": "LUDEX_SCORE",
      "source_event_name": "score_update",
      "user_id": "user_42",
      "ludex_session_id": "lsid_42",
      "correlation_id": "corr_42",
      "schema_version": 3,
      "payload": { "points": 1200 },
      "trust_origin": "validated"
    }
  ],
  "next_cursor": "eyJ0cyI6IjIwMjYtMDQtMjBUMTI6MDA6MDBaIiwiaWQiOjEwMTJ9",
  "policy": {
    "mode": "validated_plus_recovered",
    "include_recovered": true,
    "policy_hash": "..."
  },
  "scope": {
    "organization_id": "org_abc",
    "project_id": "proj_xyz",
    "environment_id": "env_prod"
  }
}
  • next_cursor is null when the page is the last one. Otherwise pass it back as the cursor query param to get the next page.
  • Field order within each data[] object matches the shared TRUSTED_EVENT_FIELDS contract; tooling that serialises row-by-row should not reorder keys.
  • The internal primary key (telemetry_events.id) is deliberately not exposed. Use event_id for customer-facing joins.

Errors

HTTP Body code Meaning
401 auth_failed Missing or invalid Bearer token.
403 insufficient_scope (reason project_mismatch) project_id query param does not match the credential.
403 insufficient_scope (reason project_wildcard_not_allowed) project_id=all on a non-wildcard credential (see Strict credential scoping).
403 insufficient_scope (reason environment_mismatch / environment_wildcard_not_allowed) Same as above, for environment_id.
400 invalid_timestamp since / until not parseable as ISO-8601.
422 (FastAPI validation) limit outside [1, 5000].

Strict credential scoping

Machine integrations reading trusted data are scoped tighter than dashboards. A read:trusted credential ties to exactly one (organization_id, project_id, environment_id) triple. This is intentional and cannot be loosened at call time:

  • project_id=all is rejected. Cross-project reads require a wildcard credential, which the v1 schema does not yet express. When wildcard credentials land (they are tracked as an additive change in a later phase), this endpoint starts honouring project_id=all only for credentials that are explicitly marked allow_wildcard=true. Until then, every project_id=all attempt returns 403 project_wildcard_not_allowed.
  • project_id must equal the credential's project when supplied explicitly. Any other value returns 403 project_mismatch and increments the ludex_trusted_events_scope_rejections_total{reason="project_mismatch"} counter.
  • environment_id follows the same rule — mismatch returns 403 environment_mismatch, wildcard returns 403 environment_wildcard_not_allowed.
  • Omitting project_id / environment_id defaults to the credential's values, never to "all". The machine contract narrows, not widens, when params are missing.
  • include_recovered can only narrow the policy. It cannot widen a validated_only policy to serve recovered rows.

Every rejection is logged at warning level (trusted_events_scope_rejected) with the reason label so operators can audit credential misuse through the Loki dashboard panel "Scope-rejected requests (security-relevant)" in grafana-dashboards/trusted_egress_dashboard.json.

Example — curl

curl -sS \
  -H "Authorization: Bearer $LUDEX_TRUSTED_API_KEY" \
  "https://api.ludex.example.com/v1/trusted/events?since=2026-04-20T00:00:00Z&limit=500"

Continuing with the cursor:

CURSOR=$(curl -sS -H "Authorization: Bearer $LUDEX_TRUSTED_API_KEY" \
  "https://api.ludex.example.com/v1/trusted/events?limit=500" | jq -r .next_cursor)

curl -sS \
  -H "Authorization: Bearer $LUDEX_TRUSTED_API_KEY" \
  "https://api.ludex.example.com/v1/trusted/events?limit=500&cursor=$CURSOR"

Example — Python

import os
import httpx

API = "https://api.ludex.example.com"
KEY = os.environ["LUDEX_TRUSTED_API_KEY"]

def iter_trusted_events(since, until=None, limit=500):
    """Yield validated trusted events for the caller's credential scope."""
    cursor = None
    while True:
        params = {"limit": limit, "since": since}
        if until is not None:
            params["until"] = until
        if cursor is not None:
            params["cursor"] = cursor
        r = httpx.get(
            f"{API}/v1/trusted/events",
            params=params,
            headers={"Authorization": f"Bearer {KEY}"},
            timeout=60.0,
        )
        r.raise_for_status()
        body = r.json()
        for row in body["data"]:
            yield row
        cursor = body.get("next_cursor")
        if cursor is None:
            return


for event in iter_trusted_events(since="2026-04-20T00:00:00Z"):
    assert event["trust_origin"] in ("validated", "recovered")

Operational notes

  • GET /v1/trusted/events is internal-only in Phase 2 — it is exposed to customers through your ingress layer. Prometheus scrapes the service's metrics port over the internal Docker network; no customer traffic reaches the metrics endpoint.
  • Panels for request rate, latency, rows served by trust_origin, and strict-scope rejections live in grafana-dashboards/trusted_egress_dashboard.json. The cross-service overview (grafana-dashboards/ludex-services-dashboard.json) includes a Trusted-Data Egress row that picks up the same metrics.
  • Loki filters JSON logs by service=ludex-egress; the trusted_events_pull_succeeded / trusted_events_pull_failed / trusted_events_scope_rejected events are the pivots to use when on-call.

See also