Chapter 15
Route a verdict to a human
The analysis chapters are detection: the lens reads what happened and forms a verdict. This chapter is the layer after it — defense. When a deterministic predicate over the lens's own verdicts fires, the lens POSTs an HMAC-signed webhook to a consumer you control: a pager, a chat channel, or an agent that picks up the work. It speaks only when something crosses a line — no "all quiet" pings.
The shape of it
You register a subscription: a predicate to watch, a window, and a webhook to call. On a schedule you own (Cloud Scheduler is the intended trigger), the lens evaluates every subscription against recent verdicts, fires the ones whose predicate matched, and dedupes a repeat of the same finding inside the window. The webhook secret proves the call came from your lens and nobody else.
Create a subscription
Writes are approver-gated. The webhook url must be https, and the secret is 16–512 characters. The body is strict — an unknown field is rejected.
POST /lens/subscriptions
Authorization: Bearer <token>
Content-Type: application/json
{
"predicate": { "kind": "conformance-fail", "surfaceId": "support.triage" },
"windowMs": 300000,
"webhook": {
"url": "https://alerts.example.com/holonograph",
"secret": "<16-512 random chars, kept secret>"
},
"approverId": "brian"
}
Success returns 201 with a redacted view of the subscription: id, agentId, the predicate and window, createdAt/createdBy, and webhook: { url } — the secret is write-only and is never read back. Set it once at create; if you lose it, delete and recreate.
Predicates — what fires an alert
A predicate is a discriminated union on kind. Each names a meaning, never an opaque number. Three ship today:
| kind | Shape | Fires when |
|---|---|---|
conformance-fail | { surfaceId, fixtureId? } | A conformance fixture FAILs on the surface (e.g. a required tool was never called). Omit fixtureId to fire on any fixture failure. |
emission-breach | { surfaceId, columnId? } | A substrate column declared always-emitted went silent in the window — a deployment is broken, not just underperforming. |
dimension-below | { surfaceId, dimensionId, threshold } | A judge dimension scored strictly below threshold in the window. The one predicate that reads a graded score. |
Read and delete
Reads are also served under the /holonograph alias. Every read redacts the webhook secret.
| Method & path | Returns |
|---|---|
GET /lens/subscriptions | All subscriptions (optional ?surfaceId= filter), each with webhook: { url } only. |
GET /lens/subscriptions/:id | One subscription, or 404. |
DELETE /lens/subscriptions/:id?approverId=<id> | Deletes it. approverId is a required query param. Returns { deleted: true, id }. |
The webhook a consumer receives
When a subscription fires, the lens POSTs this body to your url:
POST https://alerts.example.com/holonograph
x-holonograph-signature: sha256=<hex>
Content-Type: application/json
{
"subscriptionId": "sub_01H...",
"agentId": "rupert-swarm",
"predicate": { "kind": "conformance-fail", "surfaceId": "support.triage" },
"verdict": { /* structured, per predicate kind — the deterministic detail */ },
"summary": "save_report was never called on support.triage",
"fingerprint": "conformance:support.triage:...",
"firedAt": "2026-08-22T14:03:11Z"
}
verdict is a structured object, not a string — it carries the per-kind detail (the failing fixture and violating event ids, the silent column ids, or the dimension, threshold, and below-threshold event ids). summary is the one-line human sentence. fingerprint is the dedupe key.
Verify the signature — first, always
The x-holonograph-signature header is sha256= followed by the HMAC-SHA256 of the raw request body, keyed by the subscription's secret. Verify it over the raw bytes you received — before parsing JSON — and reject anything that does not match. This is the whole reason the secret exists.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyHolonographWebhook(rawBody, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const got = Buffer.from(signatureHeader ?? "");
const want = Buffer.from(expected);
// constant-time compare; length-guard first so timingSafeEqual never throws
return got.length === want.length && timingSafeEqual(got, want);
}
Required setup: the allow-list
Delivery is deny-by-default. A webhook fires only to a host on the operator's allow-list; with no allow-list, nothing fires. Point the lens at the file with APERTURE_WEBHOOK_ALLOWLIST_PATH — keep it alongside your judge rubrics. It is a JSON array of hostnames:
// $APERTURE_WEBHOOK_ALLOWLIST_PATH
[
"alerts.example.com",
"localhost"
]
| Rule | Behavior |
|---|---|
| Deny-by-default | No allow-list (absent, empty, or malformed) ⇒ every host is denied and nothing fires. |
| Exact hostname | Case-insensitive exact match. No wildcards or suffixes — *.example.com is not a thing; list each host. |
| https required | https for every remote host. http is tolerated only for loopback (localhost, 127.0.0.1, ::1) for local development. |
| No internal targets | At fire time the lens pins the resolved address and refuses private, loopback, link-local (incl. the cloud metadata address), and CGNAT ranges. It does not follow redirects. |
A demo or local bundle ships an allow-list with localhost in it so webhooks fire against a dev consumer.
Firing: the evaluate cycle
Nothing fires on its own. Point Cloud Scheduler (bearer-gated) at the evaluate endpoint on the cadence you want; each call is one evaluation cycle across all subscriptions.
POST /lens/subscriptions/evaluate
Authorization: Bearer <scheduler-token>
The response is counts only — the silence discipline again; there is no per-subscription "all quiet":
{
"evaluated": 12,
"matched": 3,
"fired": 2,
"deduped": 1,
"blocked": 0,
"skipped": 0,
"capped": 0,
"allowlistConfigured": true,
"errors": []
}
A match whose fingerprint already fired within its windowMs is suppressed (deduped), so one broken deployment is one alert, not a storm. Dedupe state is stamped only on a successful delivery, so a blocked or failed send re-attempts next cycle. Each cycle fires at most 100 webhooks; matches past that are capped and re-attempted next cycle.
Reference
Endpoints
| Method & path | Purpose |
|---|---|
POST /lens/subscriptions | Create a subscription (approver-gated). |
GET /lens/subscriptions · GET /holonograph/subscriptions | List (secret redacted). Optional ?surfaceId=. |
GET /lens/subscriptions/:id · GET /holonograph/subscriptions/:id | Read one (secret redacted). |
DELETE /lens/subscriptions/:id?approverId= | Delete (approver-gated). |
POST /lens/subscriptions/evaluate | Run one evaluation cycle (Cloud Scheduler; bearer-gated). |
Types
The wire types live in @holonograph/types-public — CreateSubscriptionRequest, SubscriptionView, SubscriptionPredicate, SubscriptionWebhookPayload, and EvaluateSubscriptionsResponse. There is no subscriptions helper in @holonograph/client; build the requests against the types and call the endpoints over HTTP.
501 rather than pretending. The Defense layer is present only where an operator has wired it.