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.

Where this sits The webhook is a trigger, not a conclusion. The usual path is: a predicate fires → a human is alerted → the human asks a coding agent to act → and the agent, before it changes anything, consults the reliability and drift reads (see Verdict reliability and Drift attribution) to decide whether the alert reflects real, trustworthy movement. The alert says what crossed a line; the reads say whether it is safe to act on.

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:

kindShapeFires 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 & pathReturns
GET /lens/subscriptionsAll subscriptions (optional ?surfaceId= filter), each with webhook: { url } only.
GET /lens/subscriptions/:idOne subscription, or 404.
DELETE /lens/subscriptions/:id?approverId=<id>Deletes it. approverId is a required query param. Returns { deleted: true, id }.
There is no update Subscriptions are create / read / delete. To change a predicate, window, or webhook, delete and recreate. There is no edit or pause endpoint on this release.

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"
]
RuleBehavior
Deny-by-defaultNo allow-list (absent, empty, or malformed) ⇒ every host is denied and nothing fires.
Exact hostnameCase-insensitive exact match. No wildcards or suffixes*.example.com is not a thing; list each host.
https requiredhttps for every remote host. http is tolerated only for loopback (localhost, 127.0.0.1, ::1) for local development.
No internal targetsAt 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 & pathPurpose
POST /lens/subscriptionsCreate a subscription (approver-gated).
GET /lens/subscriptions · GET /holonograph/subscriptionsList (secret redacted). Optional ?surfaceId=.
GET /lens/subscriptions/:id · GET /holonograph/subscriptions/:idRead one (secret redacted).
DELETE /lens/subscriptions/:id?approverId=Delete (approver-gated).
POST /lens/subscriptions/evaluateRun one evaluation cycle (Cloud Scheduler; bearer-gated).

Types

The wire types live in @holonograph/types-publicCreateSubscriptionRequest, 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.

Honest-null when unwired On a pure-collector lens with no subscription store configured, every route above answers 501 rather than pretending. The Defense layer is present only where an operator has wired it.