Shipping the trail

A security team watches its SIEM, not each vendor’s console. An event in your own D1 that should page somebody is only found by someone already looking, and the trail has one copy. Declare a sink and every event is sent to it as it is written: Splunk HEC, Datadog Logs, or a generic JSON webhook for Elastic, Sumo Logic and anything else that takes HTTPS.

Declare a sink

Sinks are a key on the audit registration in pithy.config.ts, by name:

audit({
  sinks: {
    siem: {
      profile: "splunk-hec", // or "datadog-logs", or "webhook"
      url: "https://splunk.example.com:8088/services/collector/event",
      secret: "splunk-hec-token", // the @pithy-sh/secrets name the token is read through
      actions: ["auth/", "controlplane/"], // optional: ship only these action prefixes
      detail: false, // the default: ship hashes, never IP, user-agent or metadata values
    },
  },
}),
KeyRequired
profile✓splunk-hec, datadog-logs or webhook
url✓The collector’s full HTTPS URL, path included
secret✓The @pithy-sh/secrets name the sink’s token is stored under: the HEC token, the Datadog API key, or the webhook’s bearer token
actionsShip only events whose action starts with one of these prefixes. Absent ships every event
detailfalse by default. true ships the IP, user-agent and metadata values and their salt

An unknown profile, a URL that is not HTTPS, a misspelled key or a sink with no secret name is refused at config load, before the Worker serves anything. A sink name is kebab-case, like a secret name.

Then three commands, in this order:

pithy secrets create splunk-hec-token --env prod
pithy upgrade
pithy provision --env prod

pithy secrets create stores the token, pithy upgrade writes the two queue bindings into wrangler.jsonc, AUDIT_SHIP with its consumer and AUDIT_SHIP_DLQ, and pithy provision creates the queues.

Shipping needs the secrets capability and Cloudflare Queues. A Worker that declares a sink depends on secrets, because that is where a sink’s token is read. Queues is on the Workers Paid plan, so a project with no sink binds no queue: pithy add audit writes neither, and both appear only once a sink is declared.

Declared, not toggled

A sink is reviewed and deployed like code. No control-plane route adds, changes or removes one, so no management credential can point the trail somewhere new. The only way to change where the trail goes is a deploy.

Delivered at least once, and never in the way

Each event a Worker writes goes on the AUDIT_SHIP queue once per sink that takes it. A message names the event and the sink and nothing else, so no personal value sits in a queue. The Worker’s own queue handler reads the row back and posts each sink’s share of a batch in one request. It runs in the app Worker, which already holds the sinks’ config and their tokens, so shipping works under pithy dev too.

Enqueueing happens after the write and never fails it. An audit trail that can take your login down is worse than one with a gap in it, and the same rule holds for shipping it.

A sink fails alone. One that refuses or cannot be reached retries after 30 seconds, doubling to an hour, and the other sinks still deliver. After ten retries a message goes to AUDIT_SHIP_DLQ and waits there for a person. A redirect is never followed, so the token and the batch go only to the URL you declared, and a sink answering 3xx counts as a refusal.

Redacted by default

Without detail: true, no IP, user-agent or metadata value leaves the Worker. The sink receives their salted commitments instead, the same ones the hash chain covers, which verify and disclose nothing. With detail: true the values and their salt travel, so the receiver can check the commitments too.

Decide it per sink, the way the control plane decides it per connection with audit:events:read_detail.

Verifiable at the receiver

Every shipped event is the chain’s canonical form, format pithy-audit-ship/1: its eventId, its tenant’s seq, the prev hash and its own hash. It is the same serialization pithy audit verify checks, so what a receiver holds checks against an export of the same range.

De-duplicate a redelivery on eventId, which a redelivery carries unchanged. A tenant’s seq that skips, or a prev naming a hash you never received, is where to look, and not every skip is a loss:

  • The CLI’s events are in the chain and are not shipped. pithy deploy, pithy migrate, pithy secrets and the rest record over the D1 REST API, outside any Worker, so they take a position in the chain and nothing enqueues them. Their worker is null.
  • A sink with an actions filter skips every position it does not take, by design.

So reconcile a skip against the trail: export that range with GET /audit/control-plane/export and check it with pithy audit verify. A missing event that is neither a CLI action nor filtered out is a loss. An event from before the chain ships with seq and hash null.

What each profile sends

ProfileRequest
splunk-hecAuthorization: Splunk <token>. One HEC event object per event, newline-joined: time in epoch seconds, source: "pithy", sourcetype: "pithy:audit", host the Worker, the event under event
datadog-logsDD-API-KEY: <token>. A JSON array of logs: ddsource: "pithy", service the project, hostname the Worker, ddtags with env, action and outcome, a one-line message, the event under audit
webhookAuthorization: Bearer <token>. One JSON document, { "format": "pithy-audit-ship/1", "events": [...] }

Knowing it is flowing

Audit’s manifest entry reports three health keys, behind audit:events:read, the scope the listing already takes. Each is read by primary key from pithy_audit_sinks, one row per declared sink, so the read is bounded by your config and never by the trail. A Worker that ships nothing declares none of them.

Key
auditShipBacklogEvents waiting on a retry, across every sink
auditShipDeadLetteredEvents given up on to the dead-letter queue, across every sink
auditShipDeliverydelivering when every sink attempted took its last delivery, failing when any sink’s last attempt failed, idle when nothing has been shipped yet

The totals say something is wrong; GET /audit/control-plane/sinks says which sink. Behind the same scope, it answers every declared sink in declaration order: its name, its profile, its host, its detail flag and its actions filter, beside its own lastDeliveredAt, lastFailedAt, retrying, deadLettered and delivery state. Each sink’s retrying and deadLettered add up to the manifest’s totals, and its delivery is the same rule over its own row alone. A sink never yet attempted answers null times, zero counts and idle, and a Worker with no sink answers an empty list. It never returns a token, a secret name, a URL path or query, or an event, because a path or a query may carry a key.

pithy doctor lists every sink, its profile, the host it posts to, its filter and whether it ships detail, then whether each deployed environment holds its token. An unset token is reported and never fails the exit.

What it does not do

Shipping delivers the trail. It changes nothing in it. It adds no route that writes, and no way to manage a sink at runtime. It pushes; nothing pulls, so a receiver that wants history it missed reads it from an export.

ESC