Skip to content

Latest commit

 

History

History
102 lines (78 loc) · 4.73 KB

File metadata and controls

102 lines (78 loc) · 4.73 KB

Outbound webhooks architecture

How outbound webhook delivery is structured, signed, and kept decoupled from the emitters. Public usage lives in reference/webhooks.md; this page is the design rationale + invariants for contributors.

Overview

emitter (ingest route / PolicyWriter)
        │  depends only on the WebhookPublisher Protocol (rag-core)
        ▼
WebhookDispatcher (rag-webhooks)  ──list_for_event──▶ SubscriptionStore SPI (rag-core)
        │  sign body per subscription (HMAC + timestamp)
        │  retry with backoff
        ▼
WebhookSender SPI (rag-core)  ──▶  HttpWebhookSender (rag-webhooks)  ──▶  subscriber URL

Three packages, one acyclic graph:

  • rag-core owns the wire types (WebhookEvent, WebhookSubscription, WebhookDelivery), the two SPIs (SubscriptionStore, WebhookSender) + their noop impls, and the structural WebhookPublisher Protocol.
  • rag-webhooks owns the delivery engine: signing, retry policy, the HTTP sender, and the WebhookDispatcher (the concrete WebhookPublisher). Depends only on rag-core + httpx.
  • emitters (the gateway ingest route, rag-policy's PolicyWriter) depend only on the WebhookPublisher Protocol — never on rag-webhooks.

Why a Protocol seam for emitters

Events originate in several packages (ingest, policy, later eval). If each imported rag-webhooks, the dependency graph would fan out and those packages would carry a transport dependency. Instead they depend on the WebhookPublisher Protocol in rag-core and receive a publisher by injection; the gateway is the one place that wires the concrete WebhookDispatcher. This mirrors the Retriever / GraphAdapter structural-Protocol pattern used elsewhere, and lets an emitter be tested with a recording publisher.

Fire-and-forget emission

A webhook must never delay or break the work that produced the event. So emission is fire-and-forget:

  • the ingest route calls dispatcher.schedule(ctx, event) — a background asyncio task whose errors are swallowed + logged;
  • PolicyWriter schedules its own background task on deny.

A failing subscriber, a timeout, a delivery exception — none of these surface to the ingest response or the policy decision.

Signing (replay-safe HMAC)

signing.py computes HMAC-SHA256(secret, "<timestamp>.<body>") and ships it as X-AgentContextOS-Signature: t=<ts>,v1=<hex>. Binding the timestamp into the signed string (not signing the body alone) lets verify() reject deliveries outside a freshness window — the replay defence. Verification uses a constant-time compare. The same module is used by the dispatcher (build_headers) and handed to subscribers (verify) so there's one definition of the scheme.

Delivery + retries

WebhookDispatcher._deliver runs the attempt loop per subscription: sign → WebhookSender.send → record a WebhookDeliveryAttempt; on a non-2xx / transport error, back off (RetryPolicy.backoff_for) and retry up to max_attempts. The sender never raises — a timeout or refused connection is captured in the WebhookSendOutcome so the dispatcher owns the retry decision. Delivery is at-least-once with a stable event id (ADR-0018).

The dispatcher owns no transport or persistence — both are injected SPIs — so its fan-out / retry / signing logic is unit-tested against the in-memory noop SPIs with an injected clock (stable signatures) and a no-op sleep (no real backoff delay). No network, no database, deterministic.

Secret handling

WebhookSubscription stores the HMAC secret, but redacted() masks it. The management route returns the unmasked secret only in the create response (generating one if the caller didn't supply it); list/detail return the masked copy. Subscribers copy the secret once at creation (the Stripe model).

Tenant isolation

SubscriptionStore is tenant-scoped via ctx: list_for_event only returns subscriptions in ctx.tenant_id, and get returns None for a cross-tenant id (→ 404 at the route). A tenant can neither receive another tenant's events nor probe for their subscriptions.

Reviewer checklist

  • New emitter depends on the WebhookPublisher Protocol, not rag-webhooks?
  • Emission is fire-and-forget (never blocks / fails the originating call)?
  • New event type has an events.py builder with a documented data shape?
  • New transport implements WebhookSender and never raises on a transport error?
  • Signing changes keep build_headers and verify in lock-step?
  • Tenant scoping preserved (no cross-tenant delivery / probing)?

See also