Field Recorder — Wire Protocol (any language)

The edgegate-runner Python SDK is the easiest way to record on-device AI decisions — see the setup guide. But the SDK is only a reference client of a small, stable HTTP protocol.

If your edge AI runs somewhere Python can't — a native Android/iOS app, a C++/Rust/Go/Java inference process, an RTOS with an HTTP stack — you can implement the same tamper-evident recording in any language by producing the same signed, hash-chained events and POSTing them to the two endpoints below.

Events produced this way are indistinguishable from SDK events: they appear on the dashboard timeline, are replayable, and export into the EU AI Act Article 12 report.

You probably don't need this page. If you can run CPython ≥3.10 on the device (Linux x86_64/aarch64 incl. Jetson & Snapdragon-on-Linux, macOS, Windows), just pip install edgegate-runner and follow the setup guide. This page is only for platforms the SDK can't reach.

1. Concepts

  • Device chain. Each device_id owns one append-only hash chain, starting from the genesis hash "0" × 64 (64 zeros). Each event's chain_hash binds it to the previous event, so any later edit to a past event invalidates every subsequent hash — that's what makes the log tamper-evident.
  • Signing key. Each device holds an Ed25519 key pair. You register the public key once (§3); every event is signed with the private key (§4). The server verifies signatures at ingest using the registered public key.
  • Privacy. You never send raw inputs/outputs — only their SHA-256 digests, plus an optional ≤200-char output_summary.

2. Auth & base URL

  • Base URL: https://edgegateapi.frozo.ai
  • Every request: Authorization: Bearer <API_KEY> — a workspace API key from Settings → API Keys (paid plan). The key's workspace must match {workspace_id} in the path, and the caller needs admin role on that workspace.

3. Register the signing key (once per key)

POST /v1/workspaces/{workspace_id}/recorder/keys
Authorization: Bearer <API_KEY>
Content-Type: application/json

{
  "device_id":      "forklift-07",
  "key_id":         "key-v1783399162",
  "public_key_b64": "<base64 of the 32-byte raw Ed25519 public key>"
}
  • key_id is your identifier for this key. It MUST be the exact string you stamp on every event (§4). Ingest looks keys up by this value — a mismatch means every signed event is rejected. Keep it ≤64 chars.
  • public_key_b64 is base64 of the raw 32-byte Ed25519 public key (not PEM/DER).
  • Idempotent. Re-POSTing the same key_id + public_key_b64 returns the existing key (200). Re-POSTing the same key_id with a different public key is a conflict (409) — rotate by choosing a new key_id.
  • Success → 200/201: { "key_id": "...", "device_id": "...", "provisioned_at": "..." }

Call this before the first ingest. (The Python SDK does it automatically on every sync().)

4. Build one event

For each inference you record, do these steps in order.

4a. Digests

input_digest   = hex( SHA256( serialize(input)  ) )
output_digest  = hex( SHA256( serialize(output) ) )
output_summary = first 200 chars of serialize(output)   # or "" if not recording output

serialize is your choice (the SDK uses UTF-8 JSON for structured data, raw bytes otherwise) — it only has to be stable for identical inputs on your device. The server never re-derives it.

4b. The canonical field set — exactly these 13 keys

keytypenotes
confidencefloat | nullmodel confidence, or null
device_idstringstable device id
event_idstringa UUID you generate
input_digeststringfrom 4a
latency_msfloatinference time
memory_mbfloatpeak memory (0.0 if unknown)
model_hashstringSHA-256 of the model file
model_namestringhuman-readable
output_digeststringfrom 4a
output_summarystring≤200 chars, or ""
quantizationstring"int8", "fp16", "q4_k_m", "unknown", …
silicon_idstringchip id, e.g. "Apple M4", "SM8650"
timestampstringISO-8601 UTC, e.g. "2026-07-07T12:00:00.123456+00:00"

4c. Canonicalize → chain hash

canonical  = JSON of the 13 fields, keys SORTED, NO whitespace
             (Python reference: json.dumps(fields, sort_keys=True, separators=(",", ":")))

chain_hash = hex( SHA256( previous_chain_hash + canonical ) )

previous_chain_hash is the previous event's chain_hash, or the genesis "0" × 64 for the device's first event. chain_hash is a lowercase hex SHA-256 string.

⚠️ The one cross-language gotcha: byte-identical canonical JSON

The server recomputes chain_hash from your fields. If your JSON serialization differs from the reference by a single byte, the hash won't match and the event is rejected. You MUST reproduce Python's json.dumps(..., sort_keys=True, separators=(",", ":")) byte-for-byte:

  • keys sorted lexicographically; separators , and : with no spaces;
  • JSON string escaping with ensure_ascii semantics (non-ASCII → \uXXXX);
  • null for absent confidence (do not omit the key);
  • numbers are the subtle part. Python prints 0.9 as 0.9, 1.0 as 1.0, 128 as 128 — no trailing zeros, no exponent form, integers with no decimal point. Round/format latency_ms and memory_mb on-device to values your serializer prints identically to Python's, and keep them stable.

De-risk it: unit-test your serializer against the Python reference output (or the SDK's) before shipping — that single test catches virtually all interop bugs.

4d. Sign

signature = base64( Ed25519_sign( private_key, ascii_bytes(chain_hash) ) )

You sign the chain_hash hex string's bytes — not the canonical JSON, not the raw hash bytes.

4e. The full event

The 13 canonical fields plus chain_hash, signature, and key_id:

{
  "event_id": "…", "timestamp": "…", "model_hash": "…", "model_name": "…",
  "quantization": "int8", "silicon_id": "Apple M4", "device_id": "forklift-07",
  "input_digest": "…", "output_digest": "…", "output_summary": "cat",
  "confidence": 0.91, "latency_ms": 12.4, "memory_mb": 84.0,
  "chain_hash": "…", "signature": "…", "key_id": "key-v1783399162"
}

5. Upload a batch

POST /v1/workspaces/{workspace_id}/recorder/ingest
Authorization: Bearer <API_KEY>
Content-Type: application/json

{
  "device_id": "forklift-07",
  "events": [ <event>, <event>, … ],
  "chain_start_hash": "0000…0000"
}
  • events must be in chain order.
  • chain_start_hash is the chain_hash of the last event already ingested for this device, or the genesis "0" × 64 on the very first batch. It lets the server confirm your batch continues the existing chain with no gaps.
  • Response 200: { "ingested": 3, "rejected": 0, "chain_verified": true, "last_chain_hash": "…" }

Reading the response:

  • rejected > 0 with HTTP 200 → some events failed signature verification (bad signature, or key_id not registered / doesn't match the key you provisioned). Re-check §3.
  • HTTP 422 → the hash chain didn't verify (a chain_hash didn't match the recomputed value, or chain_start_hash didn't continue the stored chain). Almost always the canonical-JSON gotcha in §4c.
  • Persist last_chain_hash and pass it as the next batch's chain_start_hash.

Ingest is idempotent on event_id — re-uploading an already-ingested event is a no-op, so retry freely.

6. Verify locally before you upload

The server does exactly two checks — run them yourself first:

  1. Chain: for each event, recompute SHA256(previous + canonical) where canonical is the JSON of every field except chain_hash, signature, key_id (the 13 in §4b), sorted, no whitespace. Compare to the event's chain_hash.
  2. Signature: Ed25519-verify base64_decode(signature) against the bytes of the chain_hash string, using the public key you registered for key_id.

7. Reference client

The Python SDK is the executable spec — when in doubt, match its output:

pip install edgegate-runner

Port the chain math and the event schema first, then unit-test your canonical JSON against the SDK's json.dumps(..., sort_keys=True, separators=(",", ":")) output. Once that test passes, the rest is plumbing.

Questions? Reach out from your dashboard, or see the Integration guide.