M2-26: Outgoing webhooks after Standard Webhooks¶
Status: ready | Depends on: M1-56, M2-01, M2-24 | Milestone: M2
Goal¶
After this ticket an owner subscribes an installed app to event types (createWebhookSubscription, webhook_out.manage, step-up), and the worker delivers every matching durable event from the event log to the app's HTTPS address as one Standard Webhooks POST (ADR 0016): the body is the event envelope of ADR 0052 with the delivery field sync_epoch, the headers are webhook-id (the event ID), webhook-timestamp and webhook-signature with v1a (Ed25519 with the server key of purpose webhook_out, public keys at getWebhookKeys) and, per subscription, v1 (HMAC-SHA256 with a secret derived from a master key and never stored). Delivery runs as the first production consumer webhooks of the M2-01 frame and the job apps.webhook_deliver: strictly in seq order per subscription, with backoff and full jitter, Retry-After honoured for 429, 502, 503 and 504, a subscription switched off by 410, by webhook.max_consecutive_failures or by a delivery that stays undelivered for webhook.retry_max_age, and a check webhook_out.deliver with the app's current grants and scopes before every attempt, so that a revoked right ends the delivery as dead without any request (AZ-M49). Every connection goes through the egress client, so a target that resolves to a private address is never contacted. Gate G10 holds the signatures against committed test vectors, the Standard Webhooks reference library (v1) and Ed25519 of the Go standard library (v1a). This is the plan's "webhooks" part of M2 (P-18: apps and webhooks use the same public API) and the server half of threat T-PL-01 ("webhooks only to allowed targets").
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" with "Goal" (P-18), "Scope" (webhooks from 06 anddocs/api/webhooks.md; ADR 0016, 0051), "Out of scope" (user interface M4), "Exit criteria" points 3 (AZ-M49), 4 (G10, M2-26) and 10, "Test strategy" (contracts G10; chaos "duplicate delivery"), "Red team" ("webhooks to private targets; exceeding the scopes of an app"; webhook targets as boundaries), "Risks"; block "agents and extensions" rows M2-01, M2-07, M2-08, M2-24, M2-25, M2-26, M2-27, M2-30 (uses the test vectors of this ticket), M2-49, M2-50; rows M6-08 (console views), M7-04 (bothy restore finalize), M7-05 (retention jobs), M7-08 (maintenance mode); section 5 "Test strategy" (row "Contract": G10 in the integration tests); section 19 P-09, P-10, P-11, P-13, P-18.- Dependencies: the header names M1-56 (
message.created, thechatstructure source with spaces, channels and threads), M2-01 and M2-24 (D-W-103). Implied through them and therefore not in the header: M1-55 (threads), M1-53 (channels), M1-52 (spaces), M1-51 (tableagentsfor the foreign keywebhook_subscriptions.agent_id), M1-46, M1-36, M1-31, M1-44, M1-35, M1-34, M1-33, M1-32 (instance.Keys, signing keys,rotateSigningKey), M1-29, M1-28 (authorize, deferred entries), M1-27, M1-26, M1-25, M1-24 (audit), M1-23, M1-22 (egress client), M1-20 (idempotency, one-time secrets), M1-18 (HTTP chain), M1-17 (route generator,api/operations.yaml), M1-16 (event log,sync.Event, envelope), M1-15 (instance.State,sync_epoch), M1-14 (jobs), M1-13 (unit of work), M1-12, M1-11, M1-10, M1-09 (secret provider, secrets catalogue), M1-08, M1-07, M1-06, M1-05, M1-03, M1-02, M1-01. Used if merged: M1-63 (mapping of a stored event to the resource of its receive action), M1-69 (canary scenario), M1-70 (tools/lint/sql_visibility.yaml), M1-71 (services/server/test/harnesswithBuildMatrixWorld). docs/api/webhooks.mdcompletely: 1 "Subscribe", 2 "Delivery" (table of headers, signed content,sync_epoch), 2.1 "Signing keys", 3 "Verification by the receiver", 4 "Retries and disabling", 5 "Order and duplicates", 6 "Delivery security", 7 "Event types for apps and agents" (generated table and the list "Not subscribable"), 9 "Configuration values", "Sources" W1 and W2.docs/spec/11-extensions.md: 1 "Principles" point 4; 2 "Agent or app"; 3.1 "Format" (events,delivery.mode,delivery.events_url); 3.2 "Validation rules" points 1 to 3; 4.1 "Installation" (the diagram writeswebhook_subscriptionsin the install transaction); 4.2 "Update"; 4.3 "Suspension and uninstallation" (row "Deliveries"); 5 "Scopes and grants"; 6 "Isolation and limits" (rows "Delivery quota", "Timeouts", "Suspension" and the last paragraph on "gap"); 7 "Outgoing webhooks" completely; 14 R-EX-06, R-EX-07; 16 "Configuration values of this chapter".docs/spec/03-data-model.md(core contract, read only): 4.11webhook_subscriptionsandwebhook_deliveries(every column, check, foreign key, index, retention class); 4.1server_signing_keys(purposewebhook_out,alg = 'EdDSA'only, states,not_after);app_installations(granted_scopes,event_subscriptions,state);event_log(audience_principal_id,receive_action, retention);audit_events(actionis an action id of 05); 1 principle 9; 6.3 "Lock order"; 6.7 "Sync epoch and restore" item 1; 8 R-DM-06, R-DM-17; 9 "Configuration values of this chapter" (webhook.allowed_hosts,webhook.max_consecutive_failures,webhook.retry_backoff_base,webhook.retry_backoff_cap).docs/spec/04-sync-protocol.md(core contract, read only): 2.2 "Envelope" (rowsync_epoch, "Delivery fields are created per delivery and are never in the event log", payload rules 1 to 5), 7.5 "Sync epoch" item 4, 7.6 "Consumers in the worker (push, webhooks)", 8.1 "Basic rule" (rights at delivery time), 9 "Delivery guarantee", 10 "Ordering", 15.6 "Webhook as transport" (agent subscriptions, see "Out of scope").docs/spec/05-actions.md(core contract, read only): 1 rules 1, 2, 3, 5 and 10; 2.2 "Target of a resource"; 2.3 "Effective level" point 5; 2.4 (columns "Owner", "Step-up", "Audit"); 2.5 "Decision"; 2.6 "Scopes for apps" (events:subscribe,messages:read, the scope-free metadata actions,webhook_in.post); 2.7 "Effect (effect)" (webhook_out.deliverhasread); 4.11 "Apps and webhooks" rowswebhook_out.read,webhook_out.manage,webhook_out.redeliver,webhook_out.deliver,webhook_out.transition(system action, R-AZ-27),app.suspend,app.resume,app.uninstall; 6 "Durable event types and receive action" (last sentence: webhooks usewebhook_out.deliver); 8 R-AZ-01, R-AZ-03, R-AZ-04, R-AZ-11, R-AZ-15 (system actions in the catalogue) and R-AZ-27 (webhook_out.transitionfor the automatic subscription transitions).docs/spec/07-permissions.md: 2.10 "Apps"; 4.1 "Decisions and reasons" (scope_missing,app_not_installed,subject_kind_not_allowed,not_self,no_grant); 4.2 "Flow" (deferred entries; filter mode for the receive check of webhooks; allow audit written by the use case); 4.3 "Visibility"; 8.1 "Delivery filter" (row "Outgoing webhooks"); 10 "Examples" and 11.1 "Test matrix" (world, G-6, row AZ-M49); 11.2 "Required tests" AZ-P1, AZ-P2, AZ-P4, AZ-P13.docs/spec/06-api.md: 4 "Authorization in the API" points 4 and 7; 5 "Errors" (points 3, 4, 7 and the generated table:validation_failed,webhook_host_not_allowed,step_up_required,forbidden,scope_missing,not_found,conflict,idempotency_key_reuse); 6 "Pagination"; 7 "Idempotency" (row "POST with a one-time secret in the response (enrollment code, API token, later webhook secrets)", R-API-26); 12 "Webhooks"; 15 "Contract tests and gate" (G10); 16 "Endpoints" groups "Discovery" (getWebhookKeys) and "Webhooks"; 19 R-API-14, R-API-22, R-API-26.docs/spec/02-architecture.md: 3.2 "Roles of a process"; 4.2 "Module list" (rowapps); 4.3 "Ports, events and sources per module" (appsportWebhooks,instanceportKeys,syncportConsumers); 4.4 "Allowed dependencies" (rowapps:instance,identity,chat,agents,messaging,sync); 4.5 "Cross-cutting ports (internal/kernel)" (kernel/authorizefilter mode); 4.6 "Actions per module" (rowapps); 5 "Layers within a module"; 8.2 "Lock order"; 8.4 "Idempotency in the Unit of Work"; 8.5 "What never happens in a transaction"; 11.5 "Worker consumers (push, webhooks)"; 12.1 "Usage", 12.2 "Queues" (delivery), 12.3 "Job catalogue" (rowapps.webhook_deliver), 12.4 "Rules for jobs" (rules 1 to 4); 13 "Configuration loading" items 3 to 6; 14.2 "Deadlines"; 14.3 "Retries".docs/spec/19-security.md: 4 SEC-CORS-2; 8 SEC-NET-1 to SEC-NET-5; 9 SEC-LOG-1 to SEC-LOG-4; 10 "Secrets" (row of the private signing keys, SEC-SEC-1, SEC-SEC-3); 12 SEC-AUD-5; 14 SEC-T09, SEC-T10.docs/spec/20-privacy.md: 2 "Data categories per table" rowswebhook_subscriptionsandwebhook_deliveries(classtransient,retention.webhook_deliveries).docs/api/openapi.yaml: operationsgetWebhookKeys,listWebhookSubscriptions,createWebhookSubscription,updateWebhookSubscription,deleteWebhookSubscription,listWebhookDeliveries,redeliverWebhook,listAgentWebhookSubscriptions,createAgentWebhookSubscription,installApp,uninstallApp,getJwks(pattern forgetWebhookKeys); schemasWebhookKeys,WebhookKey,WebhookSubscription,WebhookSubscriptionCreate,WebhookSubscriptionUpdate,WebhookSubscriptionCreated,WebhookSubscriptionList,WebhookDelivery,WebhookDeliveryStatus,WebhookDeliveryPage,EventEnvelope,App,AppInstallation; parametersWebhookId,WebhookTimestamp,WebhookSignature,AppIdPath,SubscriptionIdPath,DeliveryIdPath,Cursor,Limit,IdempotencyKey; responsesBadRequest,Unauthorized,Forbidden,NotFound,Conflict,UnprocessableEntity,TooManyRequests; the sectionwebhooks:(every entry carriesx-authz-action: webhook_out.deliver,x-receive-actionand the responses2xx,410,429"same for 502, 503 and 504").docs/spec/actions.yamlon the base branch (generated catalogue of ADR 0049): the entrieswebhook_out.*(delivery_check: trueonwebhook_out.deliver) and theevents:list withreceivers: agentfor the agent-only types.- ADRs: 0016 (all, especially "Confirmation"), 0051 (items 4 and 5, "Confirmation": "a permission revocation between round and job prevents the sending"), 0052, 0053, 0059, 0049, 0066, 0022, 0013, 0007.
docs/DECISIONS.md: D-EX-02, D-API-09, D-API-16, D-K-B15, D-K-D14, D-K-D30, D-K-D32, D-W-23, D-W-25, D-W-100, D-W-104 (R-AZ-27); section 10 "Rulings of the build milestones" (row formatD-<Ticket>-<n>).docs/security/threat-model.md: 2 TB6; 5.9 "Worker and jobs" (row "Webhook to a wrong target"); 5.11 "Apps and webhooks"; T-PL-01, T-NW-01, T-DS-01.docs/RISKS.md: RK-04 (SSRF over webhooks), RK-20 (v1awithout receiver libraries, HMAC with test vectors).docs/OPEN-QUESTIONS.mdhas no item for this ticket.docs/observability/signals.md: 5.2 "Spans" (rowwebhook.deliver), 5.3 "Attributes in spans and logs", 7 "Logs"; rowbothy.egress.blocked. No metric of signals.md belongs to this ticket;docs/observability/alerting.mdhas no webhook alarm.docs/operations/configuration.md: section 4 rowswebhook.allowed_hosts,webhook.max_consecutive_failures,webhook.retry_backoff_base,webhook.retry_backoff_cap,webhook.delivery_timeout,webhook.max_pending_per_subscription,webhook.retry_max_age,webhook.secret_rotation_overlap,webhook.receiver_tolerance(receiver only, not a server key),egress.allowed_hosts,egress.timeout,egress.max_response_bytes,sync.event_retention,consumer.batch_size,jobs.queue.delivery.max_workers,agent.jwks_max_age,secrets.cache_ttl,retention.webhook_deliveries,database.tx_retry_max,database.lock_timeout; section 5 "Secrets" (catalogue format).- M1 and M2 briefs whose interfaces you use:
docs/briefs/M2-01-worker-event-consumers.md(sync.ConsumerName,sync.ConsumerWebhooks,sync.ConsumerHandler.HandleEvent,sync.Consumers.Register, Design note 2 "handler contract", note 5 "gaps" (PlanRound,LostFrom,LostTo), note 6 "no durable events in a round"), M1-32 (instance.KeyswithSigner,VerificationKey,PublicKey,JWKS;instance.Purpose; states;getJwkswithCache-Controland CORS), M1-28 ("Public contract inkernel/authorize",authorize.Filter, "Requirement table (domain/requirements.go)", "Deferred entries", "App scopes (domain/scopes.go, 05 section 2.6)", "Visibility (domain/visibility.go, 07 section 4.3 per resource kind, ruling D4)",TestDeferredActionsGolden), M1-25 (VerifyRegistrations), M1-24 (auditing.Recorder.Record,auditing.Entry,auditing.ID,auditing.Code,auditing.Int,auditing.Bool,auditing.List), M1-22 (egress.New,egress.Options,egress.RedirectNever,egress.HostPolicy,egress.Deps,egress.ErrBlocked,egress.BlockedError), M1-20 (idempotency stage,idempotency.MarkOneTimeSecret), M1-18 (problem writer withreasonanderrors[], cursor codec inplatform/httpserver,httpservertest), M1-17 (api/operations.yaml, route generator), M1-16 (sync.Event,sync.DeliveryFields,Event.Envelope, minimum read), M1-15 (instance.State.Load,Record.SyncEpoch,platform/buildinfo.Version), M1-14 (jobs.Register,jobs.HandlerOptions,jobs.InsertOptions.ScheduledAt,jobs.Inserter.InsertTx,jobs.ValidateArgsType), M1-13 (txn.UnitOfWork.Do,txn.Key), M1-12 (pgtest.Main,pgtest.New, migration numbering, grants), M1-10 (internal/platform/otel/attr,TestAttrKeysEqualCollectorAllowlist), M1-09 (secrets.Provider.Resolve,internal/platform/secrets/stackcatalogue, generatorkey), M1-07 and M1-08 (config.Key, chapter constants,make config-reference). The brief of M2-24 forapps,app_installations, the manifest model,installAppanduninstallApp. - Code on the base branch (merged names win, D-W-100), read with
git showbefore changing anything:services/server/internal/apps(README and everything M2-24 merged),internal/sync(port with M2-01'sConsumers),internal/instance(keys, state,adapters/httpapiwithgetJwks),internal/authz(domain/requirements.go,domain/scopes.go,domain/visibility.go,domain/decide.go, the deferred list),internal/kernel/{authorize,actor,auditing,idempotency,eventlog,txn,ids,clock,errs},internal/platform/{egress,jobs,secrets,otel,httpserver,pg},internal/wire(config.go,configchapters.go,configchecks.go,egress.go, the role assembly),services/server/architecture/modules.yaml,services/server/api/operations.yaml(entries of the group "Webhooks" underlaterwith ticket M2-26, andgetWebhookKeys),services/server/migrations/,.gitleaks.toml(rulestandard-webhooks-secret, allowlist stopwordexample),infra/otel/collector.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- Tables
webhook_subscriptionsandwebhook_deliveriesexactly as 03 4.11, owned and accessed only byapps(02 4.2; 03 principle 12): every column, the check "exactly one ofapp_installation_idandagent_id",hmac_enabled OR hmac_generation = 0, the state and status checks, the foreign keys with their actions, unique(subscription_id, event_seq), the indexes(state),(agent_id) WHERE agent_id IS NOT NULLand(next_attempt_at) WHERE status = 'pending'; retention classesconfigandtransient(20). - A subscription is created, changed, paused, resumed, rotated and deleted only by an owner in a session with step-up, audited (
webhook_out.manage; 05 4.11, R-AZ-03); lists and delivery history needwebhook_out.read(owner);redeliverWebhookneedswebhook_out.redeliver(owner, audited, no step-up). An invisible resource answers404exactly like an unknown ID (05 rules 1 and 2, R-AZ-01). - The address is
httpsand its host is inwebhook.allowed_hosts, otherwise400 webhook_host_not_allowed(R-DM-17; webhooks.md 1 point 2; 06 5);event_typesis a subset of the installation'sevent_subscriptions(03; 11 7 row "Subscription"). - Delivery comes from the event log through the worker consumer
webhooksand the jobapps.webhook_deliverin queuedelivery(02 11.5, 12.3; ADR 0051; 11 6 "Deliveries come from the event log, not from a copy"); no network call inside a transaction (02 8.5, R-AR-07). - Wire format after Standard Webhooks: one
POSTper event; body = the envelope of the event (04 2.2, ADR 0052) with the delivery fieldsync_epochand no JSONnull(ADR 0053);webhook-id= event ID, unchanged across retries and redeliveries;webhook-timestamp= Unix seconds of this attempt;webhook-signature= space-separatedv1a,<base64>(Ed25519, server key of purposewebhook_out) and, whenhmac_enabled,v1,<base64>(HMAC-SHA256); signed content{webhook-id}.{webhook-timestamp}.{body}over the body bytes as sent (webhooks.md 2; 11 7; ADR 0016). - Public keys of purpose
webhook_outin the statesnext,activeandretiringatGET /api/v1/instance/webhook-keys(getWebhookKeys,instance.discovery.read, public,rl.public_auth) in the formatwhpk_<base64>(webhooks.md 2.1 point 1; 11 7 last paragraph; SEC-CORS-2). The JWKS (getJwks) never listswebhook_outkeys. - The
v1secret is never stored:HMAC-SHA256(master key, "webhook-v1:" || subscription_id || ":" || generation)with the master key from the secret provider, shown once aswhsec_<base64>;rotate_hmac_secretraiseshmac_generationand the server signs with the old and the new generation forwebhook.secret_rotation_overlap(webhooks.md 2.1 point 3; 03hmac_generation,hmac_rotated_at; 03 principle 9). Responses with a secret are one-time secrets for idempotency (06 7, R-API-26). - Strict order per subscription: the next event goes out only after the previous one is finished;
last_delivered_seqis the highestseqaccepted with2xx(webhooks.md 5; 11 7 row "Order"; 03). - Outcomes: only
2xxis success;410switches the subscription off (disabled);429,502,503and504throttle and honourRetry-After;3xxis a failure and redirects are never followed; other statuses, timeouts and connection errors are failures retried with exponential backoff and full jitter betweenwebhook.retry_backoff_baseandwebhook.retry_backoff_capuntilwebhook.retry_max_age, then the delivery isdeadand the subscriptiondisabled; afterwebhook.max_consecutive_failuresfailures in a row the subscription isdisabledand audited; response bodies are never stored (webhooks.md 4, 5 point 3; 11 7; openapiwebhooks:responses). - Before every attempt
webhook_out.deliverdecides with the app's current grants and scopes (receive action of the event type for the app, filter mode, no denial in the audit); without access the delivery isdeadwithlast_error = not_authorized, without a request and without counting as a failure (05 4.11, R-AZ-11; 07 4.2, 8.1; webhooks.md 4 point 3; 11 7 row "Check"; ADR 0051 "Confirmation"). - SSRF protection: only
https, only hosts ofwebhook.allowed_hosts, the egress client checks the address after name resolution at connection time and connects only to the checked address, loopback, link-local and private addresses are refused unlessegress.allowed_hostshas a matching entry withallow_private, no redirects, time limitwebhook.delivery_timeoutper attempt (webhooks.md 6; 11 7 row "SSRF protection"; SEC-NET-1 to SEC-NET-5). - A paused subscription continues where it stood; an event that is no longer in the log when its turn comes switches the subscription to
disabledwith the hint "gap" instead of being skipped silently, and so do events the consumerwebhooksitself lost to retention (11 6 last paragraph, R-EX-07; ADR 0051 Consequences). Abovewebhook.max_pending_per_subscriptionopen deliveries a subscription ispausedand the owner learns it (11 6 row "Delivery quota"). app.suspendpauses deliveries and keeps their state;app.uninstallsets the app's subscriptionsdisabled(11 4.3). Installing an app whose manifest hasdelivery.mode = webhookcreates its subscription in the install transaction (11 4.1).- Logs, spans and audit carry no URL path or query, no body, no secret and no content (SEC-LOG-1 to SEC-LOG-4, SEC-SEC-3, SEC-AUD-5); the span
webhook.deliver(CLIENT, worker) carriesserver.address,http.response.status_codeandbothy.outcome(signals.md 5.2). - Gate G10: committed test vectors; a delivery in an integration test verified with the Standard Webhooks reference library (
v1) and with Ed25519 of the standard library (v1a) (06 15; ADR 0016 "Confirmation").
Scope¶
All paths under services/server/ unless noted. Where a merged M1 or M2 package already has a compatible name, keep it and list the difference in the report (D-W-100).
Create:
migrations/<next free number>_webhook_subscriptions.sqlandmigrations/<next free number + 1>_webhook_deliveries.sql(goose, numbering rule of M1-12): the tables of requirement 11;GRANT SELECT, INSERT, UPDATE, DELETEon both tobothy_app. Thenmake migrations-lock.internal/apps/webhooks.go(port, standard library andinternal/kernelonly):type Webhooks interfacewithCreateSubscription(ctx context.Context, cmd CreateSubscription) (SubscriptionCreated, error),UpdateSubscription(ctx context.Context, cmd UpdateSubscription) (SubscriptionCreated, error),DeleteSubscription(ctx context.Context, id ids.ID) error,ListSubscriptions(ctx context.Context, appID ids.ID) ([]Subscription, error),ListDeliveries(ctx context.Context, q DeliveryQuery) (DeliveryPage, error),Redeliver(ctx context.Context, deliveryID ids.ID) (Delivery, error)andResumeInstallation(ctx context.Context, installationID ids.ID) error(forapp.resume, Design note 14); the DTOsSubscription,SubscriptionCreated(subscription plus optional one-timeHMACSecret),CreateSubscription,UpdateSubscription,Delivery,DeliveryQuery,DeliveryPage; the constants of the subscription states and delivery statuses of 03 and thelast_errorcodes of Design note 6.internal/apps/domain/subscription.go: pure state rulesNextSubscriptionState(current SubscriptionState, ev SubscriptionEvent) (SubscriptionState, error)for the events of Design note 5;ValidateTarget(raw string, allowed []AllowedHost) (Target, error)(Design note 9);ValidateEventTypes(types, granted []string) error;AppSubscribable(eventType string) boolover the set of Design note 10.internal/apps/domain/delivery.go:Classify(statusCode int, transportErr error) Outcome(Design note 6),Backoff(base, cap time.Duration, failedAttempts int, rnd func(n int64) int64) time.Duration(Design note 7),ParseRetryAfter(header string, now time.Time) (time.Duration, bool)(Design note 7),NextAttempt(o Outcome, attempt int, retryAfter time.Duration, hasRetryAfter bool, createdAt, now time.Time, cfg RetryConfig, rnd func(n int64) int64) (next time.Time, dead bool)combining both with the retry deadline (RetryConfigholdswebhook.retry_backoff_base,webhook.retry_backoff_capandwebhook.retry_max_age).internal/apps/domain/signature.go:SignedContent(webhookID ids.ID, ts int64, body []byte) []byte,DeriveHMACKey(master []byte, subscriptionID ids.ID, generation int) []byte,FormatSecret(key []byte) string(whsec_),ParseSecret(s string) ([]byte, error),SignatureHeader(v1a [][]byte, v1 [][]byte) string,HMACSignature(key, content []byte) []byte(Design note 11).internal/apps/app/webhooks.go(use cases of the port),internal/apps/app/consumer.go(thesync.ConsumerHandlerof the consumerwebhooks, Design note 3),internal/apps/app/deliver.go(Deliver(ctx context.Context, subscriptionID ids.ID) error, the attempt of Design note 4) and outgoing ports ininternal/apps/app/ports.go:WebhookStore(persistence),DeliveryScheduler { Schedule(ctx context.Context, subscriptionID ids.ID, at time.Time) error }(inserts a job in the ambient unit of work),WebhookSender { Send(ctx context.Context, req SignedRequest) (SendResult, error) },HMACMasterKey { Key(ctx context.Context) ([]byte, error) }.internal/apps/adapters/postgres/queries/webhooks.sql(sqlc) with the adapter implementingWebhookStore.internal/apps/adapters/jobs/webhook_deliver.go: argsWebhookDeliver struct { SubscriptionID ids.ID }withKind() = "apps.webhook_deliver", registered withjobs.HandlerOptions{Queue: "delivery"}; the handler callsDeliver; theDeliveryScheduleroverjobs.Inserter.InsertTxwithjobs.InsertOptions.ScheduledAt.internal/apps/adapters/webhookhttp/sender.go:WebhookSenderover oneegress.Clientbuilt withPurpose: "webhook",Hostsfromwebhook.allowed_hosts,Redirects: egress.RedirectNever,Timeout=webhook.delivery_timeout; it sets the headers of requirement 15 andUser-Agent: chat-server-webhooks/<server version>(webhooks.md 2, version fromplatform/buildinfo), reads only the status andRetry-After, discards the body, and opens the spanwebhook.deliver.internal/apps/adapters/hmackey/master.go:HMACMasterKeyoversecrets.Provider.Resolveofwebhook.hmac_master_key_ref, hex-decoding the value like every key of generatorkey(M1-09 D5), held in the redacting secret type.internal/apps/adapters/httpapi/webhooks.go: the strict-server methods of the six enabled operations of the group "Webhooks".internal/instance/adapters/httpapi: the strict-server methodGetWebhookKeysnext togetJwks.internal/wire/configkeys_webhook.go:webhookConfigKeys()with the keys of Design note 15 and a constantchapter11 = "11-extensions.md"(if a parallel ticket added it, keep one).docs/api/webhook-test-vectors.json(Design note 12) and a row for it indocs/api/README.md.- Tests of the section "Tests" with their fixtures.
Change:
internal/sync(port of M1-16 and M2-01):DeliveryFieldsgainsSyncEpoch ids.IDunless M1-16 already has it, andEvent.Envelopewrites the membersync_epochofEventEnvelopeonly from it (04 2.2: only in webhook deliveries); a read porttype LogReader interface { EventBySeq(ctx context.Context, seq int64) (Event, bool, error); MinRetainedSeq(ctx context.Context) (int64, error) }implemented ininternal/sync/adapters/postgres(reuse the minimum read of M1-16);type ConsumerLossHandler interface { HandleLost(ctx context.Context, from, to int64) error }in the port and its call in the round ofinternal/sync/app(Design note 3 f); README ofsync.internal/instance(port of M1-32):KeysgainsSigners(ctx context.Context, p Purpose) ([]Signer, error)(theactivekey first, then everyretiringkey whosenot_afterlies after now, nevernextorretired) andPublicKeys(ctx context.Context, p Purpose) ([]PublicKey, error)(statesnext,active,retiring); a pureFormatWebhookPublicKey(ed25519.PublicKey) string(Design note 11); README ofinstance.internal/kernel/authorize(resource.go):ResourcegainsDeliveryOf Action(receive action of the event to deliver) andAudience ids.ID(audience principal of a personal event, zero otherwise), both used only bywebhook_out.deliver.internal/authz: replace the deferred entry ofwebhook_out.deliverwith the requirement of Design note 8; removewebhook_out.deliverfrom the golden list ofTestDeferredActionsGolden; README ofauthz(deferred list, delivery check).internal/apps/domain(M2-24rules.go): the address rule of Design note 9, with a new row inTestValidateManifest_Rules:events_urlon a listed host with an unlisted port giveswebhook_host_not_allowed.internal/appsuse cases of M2-24:installAppcreates the manifest subscription anduninstallAppdisables the subscriptions in their transactions (Design note 14); ifapp.resumeexists in the merged code, it callsResumeInstallationin its transaction.internal/platform/secrets/stack: catalogue entrywebhook_hmac_master_key(generated, generatorkey, serviceserver), sobothy secrets initcreates it.internal/platform/otel/attr: constant forbothy.webhook_subscription.id;infra/otel/collector.yaml: the key inredaction/allowlist.allowed_keys("Bothy attributes").internal/wire:configKeyGroupsgainswebhookConfigKeys;configChecksgainswebhook_backoff_orderandwebhook_retry_within_retention(Design note 15);TestConfigSchemaBuildslists the new keys; the role assembly builds the webhook egress client, the sender, the master-key adapter and theappswebhook service in every role, registers the job kind in every role (theapirole inserts jobs fromredeliverWebhookand subscription changes), and in the rolesworkerandallregisters the handler forsync.ConsumerWebhookswithConsumers.Registerbefore the runner starts (replace the comment M2-01 left there).api/operations.yaml: movecreateWebhookSubscription,deleteWebhookSubscription,getWebhookKeys,listWebhookDeliveries,listWebhookSubscriptions,redeliverWebhookandupdateWebhookSubscriptiontoenabled(sorted); keeplistAgentWebhookSubscriptionsandcreateAgentWebhookSubscriptionunderlaterwithmilestone: M2and without the fieldticket(Out of scope); rerun the route generator.docs/api/openapi.yaml:WebhookDelivery.event_typeno longer inrequired, with the description of Design note 13;WebhookKey.public_keydescription names the encoding of Design note 11;updateWebhookSubscriptionandredeliverWebhookgain the response'409': $ref: '#/components/responses/Conflict'(Design notes 2 and 13); descriptions ofcreateWebhookSubscriptionandupdateWebhookSubscriptionname the one-time secret rule. Thenmake api-lint,make api-checks,make api-tablesand the route generator.docs/api/webhooks.md: 1 (validation codes of Design note 9), 2.1 (signing set and key encoding of Design note 11, HMAC encoding, a new 2.2 "Test vectors" naming the file), 4 (statuses,last_errorcodes, throttles not counted,Retry-Afterrule,failedanddead, redelivery and409 conflictwithreason: event_expired), 5 (rows while paused and disabled, gap rule), all as decided in the Design notes.docs/spec/11-extensions.md: 6 rows "Delivery quota" and the paragraph on "gap", which names both detection points, the head of a subscription and the lost range of the consumer round (Design notes 3 and 5), 7 rows "Check" and "Success" (503added; "skipped" meansdeadwithnot_authorized, not counted), 16 new rowwebhook.hmac_master_key_ref; thenmake config-reference.docs/spec/02-architecture.md: 4.3 port names (apps.Webhooks,sync.LogReader,instance.Keys.Signers), 12.3 rowapps.webhook_deliver(triggers of Design note 4), 13 item 4 (the two cross checks).docs/operations/configuration.mdsection 5: rowwebhook_hmac_master_key(generated, serviceserver,webhook.hmac_master_key_ref); section 4 regenerated.docs/observability/signals.md: 5.2 rowwebhook.deliverwith the closedbothy.outcomelist of Design note 16; 5.3 rowbothy.webhook_subscription.id.docs/security/threat-model.md5.11: the rows of Design note 17.docs/adr/0016-webhooks-per-standard-webhooks.md: "Confirmation" names the tests of this ticket and the vector file; the decision stays;docs/adr/0051-worker-consumers-of-the-event-log-with-a-cursor.md: in the last bullet of "Consequences" "last_delivered_seqdetects the gap" becomes the two detection points of Design notes 3 f and 5 e, and "Confirmation" names the revocation test andTestConsumerLossDisablesSubscriptions;make adr-index.- READMEs of
apps,sync,instance,authz;services/server/CHANGELOG.md("Unreleased");docs/DECISIONS.mdsection 10 rowsD-M2-26-1toD-M2-26-20(Design note 18).
No durable or ephemeral event type, no action id and no rights version change is added by this ticket. This ticket creates no new top-level Makefile target.
Out of scope¶
- Webhooks as agent transport (09 4.2, 04 15.6,
webhook_subscriptions.agent_id, operationslistAgentWebhookSubscriptionsandcreateAgentWebhookSubscription): every delivery to an agent needsagent.invokewith I2 and a freshly issued invocation token per delivery (R-SY-07) throughruns.Deliveryof M2-08, which is not in this ticket's dependency closure. The table carries the column and its check as 03 defines them;webhook_out.deliveranswersforbidden subject_kind_not_allowedfor agent principals until the owning ticket replaces it; the report names the plan gap. - The operations
suspendAppandresumeAppthemselves (no ticket in the plan; reported as a plan gap); this ticket makes delivery honoursuspendedand offersResumeInstallation. - Incoming webhooks
nativeandalertmanager(M2-27); commands and interactions of apps, the eventscommand.invokedandinteraction.createdto apps (M2-25); the manifest, installation and scopes (M2-24). - The TypeScript webhook verifier and the SDK handling of the vectors (M2-30; M2-29 for Python); the reference apps (M2-49, M2-50).
- Console and desktop views for subscriptions and deliveries (M6-08).
bothy restore finalizesettinglast_delivered_seqto the head and a newsync_epoch(M7-04); the maintenance mode exemption of consumerwebhooksand queuedelivery(M7-08); retention jobs of the classtransient(M7-05; Design note 6 states the rule they must keep).- The worker consumer frame, cursor, metrics and alarm (M2-01; this ticket adds only the loss call of Design note 3 f); the egress client itself (M1-22); signing key rotation (M1-32).
- Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M2-26-webhooks-outgoing; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(apps): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - No real webhook receiver, no real DNS name and no third-party service in any test: receivers are TLS test servers on
127.0.0.1with a certificate generated in the test forhooks.example.com, reached through a fake resolver of the egress client; nothing in this ticket costs money. - The Standard Webhooks reference library is a test-only dependency pinned in
go.modandgo.sumat its newest release; any local copy of a tool or library lives in a throwaway folder frommktemp -d. Never add an allowlist entry or weaken a rule of.gitleaks.toml: everywhsec_value in tests and vectors carries the markerEXAMPLE(CONTRIBUTING.md), so the existing stopword applies; derived secrets appear in the vector file only asderived_hex(Design note 12). - Never move
last_delivered_seqor the consumer cursor by hand outside a test database.
Design notes¶
-
Preconditions, before writing code. Stop with
Verdict: BLOCKED <reason>when any of these holds on the base branch: a.docs/spec/actions.yamllacks the system actionwebhook_out.transition, with which the automatic subscription transitions of Design note 5 are audited. The core contract has it (05 4.11 rowwebhook_out.transition: resourceapp, agent, rightsystem, Owner, Step-up and Permissionsno, Audityes, classnone; R-AZ-27, D-W-104), so a base branch without the entry has not taken over that spec state: stop withVerdict: BLOCKED spec state: webhook_out.transition (05 4.11, R-AZ-27) missing in docs/spec/actions.yaml on the base branch. b. The Standard Webhooks reference library for Go cannot be resolved as a Go module or its license is not onquality/licenses/allowed.txt(G10 needs it). Otherwise continue; M1-63, M1-69, M1-70 and M1-71 are used only if merged. -
Subscription use cases (
apps/app/webhooks.go), one unit of work each, in this order: the generated layer has validated the request schema (unknown fields give400 validation_failed);authorize(actor, action, Resource{Kind: app, ID: app_id})withwebhook_out.manage(create, update, delete) orwebhook_out.read(lists); for operations by subscription or delivery ID, an unknown ID gives404 not_foundbeforeauthorize, andauthorizeruns on the subscription's app, so a non-owner gets the same404as for an unknown ID (07 4.3:appis visible to owners only; AZ-P4). Then: a. Create (createWebhookSubscription): lock the app's current installation rowFOR SHARE; no installation inactiveorsuspendedgives409 conflictwithreason: app_not_installed(the reason M1-44 uses); validateurl(Design note 9,400 webhook_host_not_allowedor400 validation_failedwith pointer/url) andevent_types(non-empty, no duplicates, eachAppSubscribableand inevent_subscriptionsof the installation; otherwise400 validation_failedwith pointer/event_types/<index>anderrors[].codenot_subscribableornot_granted); insert withsigning_key_kid=KeyID()of theactivewebhook_outsigner,hmac_enabled = enable_hmac,hmac_generation = 0,state = active,created_by= actor; with HMAC derive the secret (Design note 11), return it once ashmac_secretand callidempotency.MarkOneTimeSecret(ctx), so a replay answers409 conflictwithreason: one_time_secret(06 7, R-API-26). Answer201 WebhookSubscriptionCreated. b. Update (updateWebhookSubscription, PATCH): lock the subscription rowFOR UPDATE; a newurlorevent_typesis validated as in a;state: pausedfromactiveordisabled;state: activefrompausedordisabled(owner resume and re-enable, Design note 5):consecutive_failures = 0,disabled_at = NULL, the head pending delivery (lowestevent_seqwithstatus = 'pending') getsnext_attempt_at = now, andSchedule(subscription, now);rotate_hmac_secret: truewithout HMAC gives400 validation_failedwith pointer/rotate_hmac_secretanderrors[].codehmac_not_enabled, with HMAC it setshmac_generation = hmac_generation + 1,hmac_rotated_at = now, returns the new secret once and marks the one-time secret (replay409 conflict,one_time_secret; this ticket adds the409response to the operation). An empty body changes nothing. Answer200 WebhookSubscriptionCreated. c. Delete (deleteWebhookSubscription): delete the row (deliveries go byON DELETE CASCADE);204; a second delete is404(no tombstone, 06 7 row "PUT and DELETE"). d. Lists:listWebhookSubscriptionsreturns every subscription of every installation of the app, ordered bycreated_at, thenid;listWebhookDeliveriespages by(event_seq DESC, id DESC)with the cursor codec ofplatform/httpserver, filterstatus,400 cursor_invalidfor a foreign cursor (06 6).app_id,signature_schemes([v1a]or[v1a, v1]) andevent_typeare derived (Design note 13). e. Audit: every allowed change writes one entrywebhook_out.managein its transaction throughauditing.Recorder.Record(07 4.2: the use case writes allows) withsubscription_id,app_id,change(created,updated,paused,resumed,rotated,deleted),host,event_types(list of codes),hmac_enabled,hmac_generation; never the URL path or query and never a secret (SEC-AUD-5). Denials are written byauthorize(R-AZ-04). -
The consumer
webhooks(apps/app/consumer.go), thesync.ConsumerHandlerof M2-01, runs in the round's transaction once per event inseqorder, writes onlyappsrows, jobs and the audit entries of its system transitions (webhook_out.transition, R-AZ-27, throughauditing.Recorder.Record), calls nothing outside the process and appends no durable event (M2-01 Design notes 2 and 6). For an eventev: a. Skip at once whenAppSubscribable(ev.Type)is false. b. Candidates (loaded once per round and memoised under atxn.Key): app subscriptions (app_installation_id IS NOT NULL) in stateactiveorpausedwhose installation isactiveorsuspended, withev.Typeinevent_typesand in the installation'sevent_subscriptions. Adisabledsubscription gets no rows: events that arrive while it is disabled are not delivered (Ruling for docs/DECISIONS.md,D-M2-26-1; webhooks.md 5 states it). A personal event (ev.AudiencePrincipalIDset) goes only to the subscriptions of the installation whose app principal is the audience. Rights are not checked here: they apply at delivery time (04 8.1, B7). c. Per candidate insertwebhook_deliveries (id, subscription_id, event_seq, event_id, status 'pending', attempt 0)withON CONFLICT (subscription_id, event_seq) DO NOTHING. d. Quota (11 6 "Delivery quota"; Ruling for docs/DECISIONS.md,D-M2-26-2): the open count of a subscription (rows withstatus = 'pending', counted once per round with a bounded count and then tracked in the memo) is compared before and after each insert; when an insert brings anactivesubscription from belowwebhook.max_pending_per_subscriptionto it or above, the subscription becomespaused(row lockFOR UPDATE, system transition of Design note 5, audited). Rows keep being created while paused, so no event is lost; because the rule fires on the crossing, an owner who resumes a subscription with a large backlog is not paused again before it drained below the quota and filled up again. e. Jobs: oneSchedule(subscription, now)peractivesubscription with anactiveinstallation that got at least one new row in this round (repeated wake-ups are harmless, Design note 4). f. Lost range (ADR 0051 Consequences, R-EX-07, M2-01 Design note 5): when the consumerwebhooksitself fell behindsync.event_retention, the M2-01 round reportsLostFromandLostTo. Those events never became rows, so the head rule of note 5 e cannot see them.internal/syncgainstype ConsumerLossHandler interface { HandleLost(ctx context.Context, from, to int64) error }. WhenPlanRoundreports a loss and the registered handler implements this interface, the round calls it once, in the round's transaction, before the firstHandleEvent. An error rolls back the round like a handler error. Consumers without the interface (for examplepush) are unaffected. Thewebhookshandler implements it as follows: every app subscription in stateactiveorpausedwhose installation isactiveorsuspendedbecomesdisabledwith causegap(system transition of note 5 b,disabled_at = now), because any of them could have matched a lost event. Theirpendingrows all belong to events belowfrom, which left the log as well, so they becomedeadwithlast_error = event_expiredas in note 5 e; the audit entry namesfrom_seq(the lowestevent_seqof those rows, elsefrom) andto_seq=to. A re-enable then continues with the events the consumer reads after it, so it never loops. The candidates of note 3 b are loaded after this step. -
The attempt (
apps/app/deliver.go, jobapps.webhook_deliverwith args{SubscriptionID}; Ruling for docs/DECISIONS.md,D-M2-26-3). One job kind serves every trigger: a round (note 3 e), the chain after a finished delivery, a scheduled retry, the lease wake-up (note 4 e), an owner resume or re-enable (note 2 b),redeliverWebhook(note 13) andResumeInstallation(note 14). Several jobs for one subscription are harmless: all serialize on its row lock and only a due head is sent, so at most one attempt per subscription is in flight (strict order, requirement 18). a. Claim, one unit of work:SELECT ... FROM webhook_subscriptions WHERE id = $1 FOR UPDATE; missing or notactive: done. Installation notactive(for examplesuspended): done without change. Head = thependingrow with the lowestevent_seq; none: done.next_attempt_atafter now: done (a scheduled retry, the lease wake-up or the attempt in flight owns it). b. Event:sync.LogReader.EventBySeq(head.event_seq); not retained: gap (note 5 e), done. c. Routing:ev.Typeno longer in the subscription'sevent_typesor in the installation'sevent_subscriptions(11 4.2 "Removed ... events ... take effect immediately"): headdeadwithlast_error = not_subscribed, chain, done. d. Rights:authorize(authorize.Filter(ctx), appPrincipal, webhook_out.deliver, Resource{Kind: ev.ResourceKind, ID: ev.ResourceID, SpaceID, ChannelID, ThreadID from ev, DeliveryOf: ev.ReceiveAction, Audience: ev.AudiencePrincipalID})withappPrincipal = actor.Principal{ID: app principal, Kind: app, InstallationID, GrantedScopes}read in this unit of work (current grants, scopes and installation). Any outcome other thanallow: headdeadwithlast_error = not_authorized,attemptunchanged, no request,consecutive_failuresunchanged, chain, done (requirement 20, AZ-M49). Anerror(not a decision) returns the error, and River retries the job. If M1-63 merged a mapping from a stored event to the resource of its receive action, use it instead of building the descriptor here. e. Request: body =json.Marshal(ev.Envelope(sync.DeliveryFields{SyncEpoch: instance.State.Load(ctx).SyncEpoch}));ts=clock.Now()in Unix seconds; signatures of note 11. Lease:attempt = attempt + 1,next_attempt_at = now + webhook.delivery_timeout, and in the same unit of workSchedule(subscription, next_attempt_at)as the lease wake-up, so an attempt whose send or record step ends with an error, a cancellation or a crash is sent again once the lease has run out; commit. Keep body and header in memory only. f. Send throughWebhookSenderoutside any transaction (02 8.5). g. Record, one unit of work: lock the subscription row, reload the head; if it is no longerpendingwith the claimedattempt(deleted, redelivered, paused and changed), discard the result. Otherwise apply the outcome of note 6 with the timing of note 7, setresponse_codewhen a response arrived, and chain:Schedule(subscription, now)after a finished delivery while the subscription staysactive;Schedule(subscription, next_attempt_at)after a retryable outcome; nothing when the subscription becamedisabledor is no longeractive. h. On a 2xx: headsucceeded,delivered_at = now,next_attempt_at = NULL,last_error = NULL; subscriptionlast_delivered_seq = GREATEST(last_delivered_seq, event_seq),consecutive_failures = 0,signing_key_kid= the kid of the active key that signed (Ruling for docs/DECISIONS.md,D-M2-26-4: the field names the key that signed the last accepted delivery, at creation the active key). Locks: the subscription row first, then its delivery rows; a round locks a subscription row only for the quota transition and never waits for the consumer cursor (M2-01), so no cycle exists. The job's own failures (database, context cancelled) return the error and River retries with its defaults (M1-14 rules 15 and 16); a retry that still finds the lease ahead ends, and the lease wake-up of e sends the head again; the attempt is idempotent throughattemptand the lease. A crash after the receiver answered and before g leads to a second send with the samewebhook-id(at least once, webhooks.md 5 point 1; the receiver deduplicates). -
Subscription states (
domain/subscription.go; 03state; webhooks.md 4 and 5; 11 6): a. Owner:active -> paused,paused -> active,disabled -> active(re-enable),disabled -> paused; resume and re-enable resetconsecutive_failuresand make the head due now (note 2 b). b. System, each audited with the system actionwebhook_out.transition(05 4.11, R-AZ-27; actor none, resourceapp, detailssubscription_id,from_state,to_state,cause, andfrom_seq,to_seqfor a gap), in the transaction of the change, and logged at warn level:410->disabled(causegone);consecutive_failuresreacheswebhook.max_consecutive_failures->disabled(causemax_consecutive_failures; the head stayspendingand goes first after a re-enable); a deliverydeadafterwebhook.retry_max_age->disabled(causeretry_max_age); a gap ->disabled(causegap); the quota of note 3 d ->paused(causemax_pending).disabled_at = nowon every change todisabled. "the owner is notified" (11 6) is this audit entry plus the state inlistWebhookSubscriptions; the product sends no mail (webhooks.md 4 point 2) and 05 has no event type for subscriptions. c.uninstallAppsets every subscription of the installationdisabledin its transaction (11 4.3; covered by the audit entry ofapp.uninstall). d. After a re-enable delivery continues with the remainingpendingrows inseqorder and then with events committed after it (webhooks.md 5 point 3 "next event"). e. Gap (Ruling for docs/DECISIONS.md,D-M2-26-5; 11 6, R-EX-07): detected at the head when its event is no longer in the log (EventBySeqfalse). In one unit of work: subscriptiondisabled(causegap), everypendingrow of the subscription withevent_seqbelowMinRetainedSeq()becomesdeadwithlast_error = event_expired, the audit entry names the first and last lostseq. A re-enable then continues with the first retained pending row, so it never loops. Among the rows that exist, this detects exactly the deliveries that were due, without false positives for subscriptions that match rarely. A loss inside the consumer itself is detected by note 3 f. -
Outcomes and statuses (
domain/delivery.goClassify; Ruling for docs/DECISIONS.md,D-M2-26-6). Statuses of 03:pending(waiting for its first or next attempt, also after a failed attempt;next_attempt_atset),succeeded(2xx),failed(finished without success because the receiver answered410; terminal, the owner may redeliver),dead(finished:webhook.retry_max_agepassed after a failure,not_authorized,not_subscribed,event_expired).last_erroris a code from the closed listtimeout,connection_failed,tls_failed,address_blocked,host_not_allowed,http_status,throttled,gone,not_authorized,not_subscribed,event_expired(never a response body, 03; webhooks.md 4 point 5). Mapping: 2xx -> success;410->failed,gone, subscriptiondisabled;429,502,503,504-> retry,throttled, not counted; any other status including3xx(the egress client returns it unchanged withRedirectNever) -> retry,http_status, counted;egress.ErrBlockedwith reasonaddress_blocked-> retry,address_blocked, counted; reasonshost_not_allowed,scheme_not_allowed,url_not_allowed-> retry,host_not_allowed, counted; deadline ->timeout; TLS handshake or certificate error ->tls_failed; other transport errors ->connection_failed; all counted. "Counted" meansconsecutive_failures + 1, compared withwebhook.max_consecutive_failures(webhooks.md 4 table separates "Throttle" from "Failure"; only "failures" count, point 2). Retention (M7-05) may delete onlysucceeded,failedanddeadrows older thanretention.webhook_deliveries, neverpendingones: state this in theappsREADME and in webhooks.md 4. -
Timing (Ruling for docs/DECISIONS.md,
D-M2-26-7). After the n-th counted or throttled failure in a row of this delivery (n =attempt), the backoff is a uniform random duration in [webhook.retry_backoff_base, min(webhook.retry_backoff_cap,webhook.retry_backoff_basetimes two to the power n-1)]: exponential with full jitter between base and cap (webhooks.md 4 point 1), the random source injected for tests. For a throttle with a validRetry-After(delta-seconds or HTTP-date of RFC 9110 10.2.3; a past date counts as zero; anything else is ignored) the delay is the larger of backoff andRetry-After(webhooks.md 4 table). The retry deadline iscreated_at + webhook.retry_max_age: a retry is never scheduled after it (a later time is cut to the deadline), and a counted or throttled failure at or after it makes the deliverydeadand the subscriptiondisabled(webhooks.md 5 point 3). The deadline is checked only after a failed attempt, never before sending, so a head that waited in a paused or disabled subscription still gets its attempt after the resume. -
webhook_out.deliverinauthz(07 4.2 "Requirements that need facts of a later milestone ... The ticket that delivers the facts replaces the entry"). Requirement atomDeliveryindomain/requirements.go, evaluated indecide.go, in this order: principal kindapp(anagentgivesforbidden subject_kind_not_alloweduntil the agent transport, Out of scope; any other kind the same); the liveness check of M1-28 has run (app_not_installedfor an installation that is notactive); scopeevents:subscribeinGrantedScopes, elseforbidden scope_missing(05 2.6 "Event subscriptions"); withAudienceset:Audienceequal to the principal, elsenot_found not_self, thenallow(receive actionself, and for appsagent.events.consumeis replaced byevents:subscribe, 03event_log.receive_action); otherwise the full decision ofDeliveryOfon the same resource (visibility, requirement, the app scope ofDeliveryOffromdomain/scopes.go, for examplemessages:readformessage.read, none forinstance.read,status.read,command.read,agent.read), whose outcome is the result.DeliveryOfempty or itselfwebhook_out.deliveris an internal error. Only in filter mode from the job: no denial is written and no decision is counted (07 4.2, AZ-P13).writes(webhook_out.deliver)stays false (M1-28). Owners have no special rule here: an app is never owner. The rights version does not change. -
Targets (
domain/subscription.goValidateTarget; Ruling for docs/DECISIONS.md,D-M2-26-8): entries ofwebhook.allowed_hostshave the formhost[:port](host lower case, no scheme, no path; without port only the default port ofhttps); a URL is accepted when it parses, its scheme ishttps, it has no user info and no fragment, and its canonical host (lower case, no trailing dot) and port match an entry; otherwise400 webhook_host_not_allowedfor a host or port not in the list and400 validation_failed(pointer/url) for every other defect. The same list is theHostPolicyof the webhook egress client, so a list changed after a restart blocks old subscriptions withhost_not_allowedat delivery (counted). Private addresses need an entry inegress.allowed_hostswithallow_private(operator; webhooks.md 6 point 2); a subscription never openshttp. This rule replaces the host-without-port comparison of M2-24 Design note 5:domain.ValidateManifestchecksdelivery.events_urlanddelivery.interactions_urlwithValidateTargetand the same parsed[]AllowedHost(host or port not listed: item codewebhook_host_not_allowed; other defects keep M2-24's codes). Manifest addresses, subscription URLs and the egressHostPolicythen follow onehost[:port]rule, and the install check that Design note 14 relies on is this check. -
Subscribable types (Ruling for docs/DECISIONS.md,
D-M2-26-9):AppSubscribableis the set of keys underwebhooks:ofdocs/api/openapi.yamlminus the event types withreceivers: agentindocs/spec/actions.yaml(the "agent subscription only" rows of webhooks.md 7). Keep it as a hand-written table indomainwith a test that reads both files from the repository and fails on any difference. -
Signatures (Rulings for docs/DECISIONS.md,
D-M2-26-10andD-M2-26-11): a.v1a: one signature per signer ofinstance.Keys.Signers(ctx, webhook_out)(active first, then everyretiringkey whosenot_afterlies ahead; webhooks.md 2.1 point 2: "the server signs with both until the old one reachesnot_after"). With the promotion rule of M1-32 (not_after= time of promotion) this is the active key alone; the rule stays general so a later change of the window needs no change here. b.v1whenhmac_enabled: key ofhmac_generation, plus the key ofhmac_generation - 1whilehmac_rotated_at + webhook.secret_rotation_overlaplies ahead; never older generations. c. Header: entries separated by one space, allv1aentries first in signer order, then thev1entries newest generation first; each entry<scheme>,<standard base64 with padding>(webhooks.md 2). d. HMAC derivation:HMAC-SHA256(master, "webhook-v1:" + subscription_id + ":" + generation)with the subscription ID in canonical lower-case hyphenated form and the generation in decimal ASCII; the master key is the hex-decoded value ofwebhook.hmac_master_key_ref(M1-09 D5). The derived bytes are the HMAC key;whsec_+ standard base64 of them is the secret shown once, the form the reference libraries decode. e.whpk_+ standard base64 of the DER-encoded SubjectPublicKeyInfo (RFC 8410) of the Ed25519 key, the form of the example undergetWebhookKeys(RulingD-M2-26-11; the openapi description ofWebhookKey.public_keynames it). f.getWebhookKeys(instance/adapters/httpapi):PublicKeys(ctx, webhook_out)ordered by state (active,next,retiring) andkid; each item{kid, public_key, state, not_after}withnot_afteronly when set (nonull);Cache-ControlandAccess-Control-Allow-Origin: *exactly asgetJwkssets them (M1-32: anextkey is publishedagent.jwks_max_agebefore it signs; SEC-CORS-2). g. Secrets never leave memory except in the one response: no log, no audit, no error text, no row (includingidempotency_keys), no span (SEC-SEC-3, R-API-26). -
Test vectors (Ruling for docs/DECISIONS.md,
D-M2-26-12):docs/api/webhook-test-vectors.json, the contract file for every verifier (M2-29, M2-30):{"version": 1, "derivation": [...], "signatures": [...]}; each derivation case{master_hex, subscription_id, generation, derived_hex}, withderived_hexthe lowercase hex of the 32 derived bytes. The secret shown to the owner iswhsec_+ standard base64 of those bytes (note 11 d) and is not stored in the file, because a derived value cannot carry theEXAMPLEmarker.master_hexvalues are low-entropy fixed byte sequences (for example the bytes 00 to 1f), so no gitleaks rule matches them. Thehmac_secretsof the signature cases are chosenwhsec_EXAMPLE...values (valid standard base64), not derived ones; each signature case{name, webhook_id, timestamp, body, ed25519_seed_base64, public_key, hmac_secrets, signature_header}with at least: one key and one secret; twov1asigners; two HMAC generations (overlap); a body with non-ASCII characters and escapes; a body withtruncated: true. Values are generated once by a test helper in this ticket from fixed seeds and checked in; everywhsec_value containsEXAMPLE(gitleaks stopword). webhooks.md 2.2 documents the format and the field names. -
Deliveries API (Rulings for docs/DECISIONS.md,
D-M2-26-13andD-M2-26-14): a.event_typeis not a column of 03webhook_deliveries; the list reads it withEventBySeqwhile the event is retained and omits it afterwards.WebhookDelivery.event_typeleavesrequiredin openapi.yaml with the description "absent once the event left the event log (sync.event_retention)" (the core contract wins over the API document; spec gap reported). b.redeliverWebhook(webhook_out.redeliver, resourcewebhook_delivery): apendingrow getsnext_attempt_at = nowand a job (retry now); asucceeded,failedordeadrow becomespendingagain withnext_attempt_at = now,last_errorandresponse_codecleared,attemptkept andcreated_at = now(), so the retry window ofwebhook.retry_max_agestarts again: a redelivery is a new delivery of the same event with the samewebhook-id(webhooks.md 4 point 4). Being the lowestpendingseq, it goes out before the subscription's later pending rows (strict order). An event that is no longer in the log gives409 conflictwithreason: event_expired(this ticket adds the409response to the operation). A paused or disabled subscription keeps the row waiting. Answer202 WebhookDelivery; auditwebhook_out.redeliverwithdelivery_id,subscription_id,event_seq,previous_status;Idempotency-Keythrough M1-20. -
Install, uninstall, suspend (Ruling for docs/DECISIONS.md,
D-M2-26-15; 11 4.1, 4.2, 4.3): the install use case of M2-24 creates, when the manifest hasdelivery.mode = webhook, one subscription withurl = delivery.events_url,event_types= the installation'sevent_subscriptions, no HMAC andcreated_by= the installing owner, in the install transaction, afterauthorize(webhook_out.manage)and with its own audit entry (change = created, samecorrelation_id); the host check of M2-24 (11 3.2 point 3) already ran. Upgrades and scope changes never change existing subscriptions: removed event types stop at delivery (note 4 c), new ones need an ownerupdateWebhookSubscription. Uninstall disables (note 5 c). Asuspendedinstallation keeps getting rows and sends nothing (note 4 a);ResumeInstallationmakes the head of eachactivesubscription of the installation due now and schedules a job, in the caller's unit of work. -
Configuration (Ruling for docs/DECISIONS.md,
D-M2-26-16):internal/wire/configkeys_webhook.goregisterswebhook.max_consecutive_failures(int, at least 1),webhook.retry_backoff_base,webhook.retry_backoff_cap(durations above zero) withchapter03, andwebhook.max_pending_per_subscription(int, at least 1),webhook.retry_max_age,webhook.secret_rotation_overlap(durations above zero) and the newwebhook.hmac_master_key_ref(the kind the merged schema uses for*_refkeys such asdatabase.password_ref, defaultfile:<secrets.dir>/webhook_hmac_master_key) withchapter11; keepwebhook.delivery_timeoutas M1-32 registered it; keepwebhook.allowed_hostsas M2-24 registered it inconfigkeys_apps.go(one registration; Design note 9 defines its entry form);webhook.receiver_toleranceis a receiver value and gets no server key (D-W-23). Cross checks inconfigchecks.go, added to 02 13 item 4 "Cross conditions today":webhook_backoff_order(webhook.retry_backoff_baseat mostwebhook.retry_backoff_cap) andwebhook_retry_within_retention(webhook.retry_max_agestrictly belowsync.event_retention, because a retry needs its event in the log); messages name the keys, never a value. New row in 11 16:webhook.hmac_master_key_ref | Server | file:<secrets.dir>/webhook_hmac_master_key | reference to the master key from which the HMAC secrets of the subscriptions are derived ([webhooks.md](../api/webhooks.md), section 2.1); row in configuration.md 5:webhook_hmac_master_key | generated | server | webhook.hmac_master_key_ref: master key of the derived webhook secrets. Changing the master key changes every derived secret; theappsREADME says so and points torotate_hmac_secret. -
Telemetry (Ruling for docs/DECISIONS.md,
D-M2-26-17): spanwebhook.deliveraround the HTTP exchange withserver.address(host only),http.response.status_codewhen a response arrived,bothy.outcomefrom the closed listsucceeded,throttled,gone,failed,blocked. Logs with constant English messages, attributes only from signals.md 5.3:webhook delivery failed(info) andwebhook subscription state changed(warn) withbothy.webhook_subscription.id(new attribute, UUID, never on metrics),bothy.event.seq,bothy.event.type,bothy.reason(thelast_errorcode or the cause of note 5 b) andserver.address. Add the attribute to signals.md 5.3,otel/attrand the collector allowlist in the same change. No metric is added;bothy.egress.blockedof M1-22 counts refused attempts. -
Threat model 5.11, rows to add (English, like the table): S | Forged or replayed webhook at the receiver |
v1aalways,v1per subscription,webhook-timestampandwebhook-idfor freshness and duplicates, test vectors (G10) | T-PL-01; I | App receives events outside its grants or scopes |webhook_out.deliverbefore every attempt with current permissions,deadwithout a request | T-PL-01; I | Webhook to a private target (DNS rebinding) | host listwebhook.allowed_hosts, egress client with an address check at connection setup, no redirects | T-NW-01; I | HMAC secret from the database or the idempotency store | derived, never stored, one-time response | T-TK-02; D | Slow receiver congests deliveries | order per subscription, queuedelivery, delivery quota, disabling | T-DS-01. -
Rulings for
docs/DECISIONS.mdsection 10, one row each with date, decision, reason and place:D-M2-26-1rows only foractiveandpausedsubscriptions, none whiledisabled(note 3 b);D-M2-26-2quota as pause trigger on the crossing (note 3 d);D-M2-26-3one job kind per subscription with chain, lease and lease wake-up (note 4);D-M2-26-4signing_key_kidnames the key of the last accepted delivery (note 4 h);D-M2-26-5gap detected at the head and from the lost range of the round, all expired pending rowsdead(notes 3 f and 5 e);D-M2-26-6statuses,last_errorcodes, throttles not counted (note 6);D-M2-26-7backoff,Retry-Afterand deadline (note 7);D-M2-26-8target rules and thehost[:port]form, replacing the host-without-port comparison of M2-24 Design note 5 (note 9);D-M2-26-9subscribable set (note 10);D-M2-26-10signing set, header order, HMAC overlap and derivation encoding (note 11 a to d);D-M2-26-11whpk_as DER SubjectPublicKeyInfo (note 11 e);D-M2-26-12test vector file, derived secrets only asderived_hex(note 12);D-M2-26-13event_typederived and optional (note 13 a);D-M2-26-14redelivery semantics andevent_expired(note 13 b);D-M2-26-15install, uninstall, suspend (note 14);D-M2-26-16configuration keys, master key secret and cross checks (note 15);D-M2-26-17telemetry (note 16);D-M2-26-18validation codesnot_subscribable,not_granted,hmac_not_enabledand the reasonsapp_not_installed,one_time_secret,event_expired(notes 2 and 13);D-M2-26-19agent subscriptions not delivered andsubject_kind_not_allowedfor agents until the owning ticket (Out of scope);D-M2-26-20deliveries listed newest first (note 2 d). -
How the M2 section applies. "Goal" (P-18): the delivery uses the same envelope as gateway and REST. "Scope": webhooks of 06 and webhooks.md, ADR 0016 and 0051. "Exit criteria" 3 and 4: AZ-M49 and G10 run in
make checkwith the integration tests. "Test strategy": the chaos item "duplicate delivery" is the crash test of note 4 (samewebhook-id). "Red team": "webhooks to private targets" and "exceeding the scopes of an app" are the testsTestDeliveryPrivateAddressRefusedandTestDeliveryScopeRemovedIsDead; webhook targets are a boundary in the threat model (note 17). "Risks": RK-04 and RK-20 are reduced by notes 9, 11 and 12; name them in the report.
Tests (write them first)¶
Unit:
internal/apps/domain:TestClassify(table:200,204,299success;410gone;429,502,503,504throttled;300,302,307,400,401,404,500,501countedhttp_status; each egress reason, a deadline, a TLS error and a reset connection to their codes).TestBackoffandFuzzBackoff(native Go fuzz test whose seeds run ingo test): every result lies in [base,cap]; the first retry waits exactlybase; the upper bound doubles up tocap; the injected random source drives the jitter.TestParseRetryAfterandFuzzParseRetryAfter: delta-seconds, HTTP-date, a past date, negative, empty, garbage; never panics.TestNextAttempt: backoff versusRetry-After, the cut at the deadline,deadat and after the deadline.TestSubscriptionTransitions: every owner and system transition of Design note 5 allowed, every other rejected (for example quota on apausedordisabledsubscription changes nothing).TestValidateTargetandFuzzValidateTarget:httpsonly, user info and fragment rejected, host not listed giveswebhook_host_not_allowed, port rules, upper case and trailing dot normalised, IP literal not listed rejected; no input panics and an accepted URL always has schemehttps.TestValidateEventTypes: empty, duplicates,not_subscribable,not_granted.TestAppSubscribableMatchesContract: the set equals thewebhooks:keys of openapi.yaml minusreceivers: agentof actions.yaml (Design note 10).TestSignatureHeaderFormat,TestHMACOverlap(generation and the previous one only insidewebhook.secret_rotation_overlap),TestDeriveHMACKeyStable(a derivation vector of the file; another subscription or generation gives another key).- G10, part 1 (
internal/apps/domain):TestWebhookTestVectors: for each case ofdocs/api/webhook-test-vectors.jsonthe domain functions reproducesignature_headerbyte for byte from seed, secrets, ID, timestamp and body; eachv1asignature verifies withcrypto/ed25519against the key decoded frompublic_key; eachv1signature verifies with the Standard Webhooks reference library; a body changed by one byte fails both; each derivation case reproducesderived_hexwithDeriveHMACKey, andParseSecret(FormatSecret(key))returns the same bytes. internal/sync/app:TestRoundCallsHandleLost(a handler implementingConsumerLossHandlergets the lost range once, before the first event; an error rolls back the round, so neither the cursor nor a job moves; a handler without the interface runs as before).internal/instance:TestFormatWebhookPublicKey(round trip throughx509.ParsePKIXPublicKey, prefixwhpk_),TestSignersOrder(active first,retiringwithnot_afterahead included, pastnot_after,nextandretiredexcluded),TestPublicKeysWebhookOut.internal/authz:TestWebhookOutDeliverRequirement(table: app with grant and scopesevents:subscribeandmessages:readonmessage.createdgivesallow; no grant on the channel givesnot_found; withoutmessages:readgivesforbidden scope_missing; withoutevents:subscribegivesforbidden scope_missing; personal event for another principal givesnot_found; personal event for the app itself givesallow;instance.updatedneeds onlyevents:subscribe; agent principal givesforbidden subject_kind_not_allowed; emptyDeliveryOfis an internal error);TestDeferredActionsGoldenwithoutwebhook_out.deliver;TestWebhookDeliverFilterWritesNoDenial(AZ-P13).internal/wire:TestConfigCheckWebhookBackoffOrder,TestConfigCheckWebhookRetryWithinRetention(equal fails, greater fails, smaller passes; messages name both keys and no value);TestConfigSchemaBuildswith the new keys;TestWebhookConsumerRegisteredInWorkerRoles;TestWebhookJobRegistration(kind, queuedelivery, args passjobs.ValidateArgsType).internal/platform/secrets/stack: the catalogue test of M1-09 passes with the new row in configuration.md 5.
Integration (PostgreSQL 18 via pgtest, roles from migrate --bootstrap, runtime as bothy_app; the world of 07 10 and 11.1 from BuildMatrixWorld of M1-71 if merged, otherwise from the test helpers of the modules: spaces Support (channels Allgemein and Eskalation) and Marketing, people Alex and Sam, app Tracker installed with scopes events:subscribe, messages:read, messages:write and event_subscriptions = [message.created] and grant G-6 (write, Support/Eskalation); receivers are TLS test servers of the Boundaries; a fake clock where noted; jobs run through a jobs.Runtime in role worker, or in role api where a test needs rows without running jobs):
TestWebhookTablesMatchDataModel: columns, types, nullability, defaults, checks (both exclusivity checks with SQLSTATE23514), foreign keys with their actions, unique key, indexes;bothy_appprivileges;TestPrivilegesAfterAllMigrationsof M1-12 still passes.TestCreateWebhookSubscription: owner with fresh step-up gets201withsignature_schemes = [v1a],signing_key_kidof the active key, nohmac_secret; withenable_hmacthe secret appears once with prefixwhsec_andsignature_schemes = [v1a, v1]; replay with the sameIdempotency-Keyanswers409 conflictwithreason: one_time_secretandIdempotent-Replay: true, and no row ofidempotency_keyscontains any part of the secret; without HMAC the replay returns the stored201; host not listed400 webhook_host_not_allowed;httpURL400 validation_failedwith pointer/url; type not granted400 validation_failedwitherrors[].code = not_granted; uninstalled app409 conflictwithreason: app_not_installed; Alex404with the same status, body and headers as an unknown app ID; owner with stale step-up401 step_up_required; owner through a personal token404; one audit entry with host and without path.TestUpdateWebhookSubscription: pause, resume, re-enable fromdisabled(failures reset, head due, a job inserted);rotate_hmac_secretraises the generation, returns the new secret once, and deliveries insidewebhook.secret_rotation_overlapcarry both generations, after it only the new one; rotation without HMAC400 validation_failedwithhmac_not_enabled; replay of a rotation409 conflictone_time_secret; a new host is checked.TestDeleteWebhookSubscription:204, deliveries gone, second delete404, audit entry.TestListWebhookSubscriptionsAndDeliveries: order,app_id, pagination withnext_cursor,statusfilter,400 cursor_invalid; a delivery whose event left the log has noevent_typeand validates againstWebhookDelivery.TestGetWebhookKeys: anonymous200; onlywebhook_outkeys innext,active,retiring;public_keywith prefixwhpk_decoding to the key;Cache-ControlandAccess-Control-Allow-OriginasgetJwks; the body validates againstWebhookKeys;getJwkscontains nowebhook_outkey.TestWebhookRoundCreatesDeliveries: one row peractiveandpausedcandidate, none fordisabledsubscriptions or uninstalled installations, none for types outsideevent_typesorevent_subscriptions; a personal event only for the audience app; one job peractivesubscription per round; a second round over the same events changes nothing.- G10, part 2:
TestWebhookDeliveryG10: Alex posts in Support/Eskalation; the receiver gets onePOSTwithContent-Type: application/json,User-Agentchat-server-webhooks/<version>,webhook-id= event ID,webhook-timestampof the attempt; the body is the envelope of the stored event withsync_epoch=instance.sync_epochand contains nonull;v1verifies with the reference library and thewhsec_secret from the create response;v1averifies withcrypto/ed25519and the key fromgetWebhookKeys; the row issucceededwithresponse_codeanddelivered_at;last_delivered_seqequals the event'sseq. Use the real clock or a fake clock set to now, because the reference library checks the timestamp tolerance. TestAZM49: Tracker's subscription onmessage.created; Alex posts in Support/Allgemein and then in Support/Eskalation; the receiver gets exactly the Eskalation event; the Allgemein row isdeadwithlast_error = not_authorized,attempt = 0, noresponse_code;consecutive_failuresis zero; no audit denial is written.TestDeliveryRevokedRightIsDeadWithoutRequest(plan: "a revocation of permissions yieldsdeadwithout a request"; ADR 0051 "Confirmation"): the round creates the row with jobs held back (roleapi), the owner revokes G-6, then the job runs: no request reaches the receiver, the row isdeadwithnot_authorized, the next event of the subscription goes out after a new grant. VariantTestDeliveryScopeRemovedIsDead:setAppScopesremovesmessages:readbetween round and job, same result (Red Team "exceeding the scopes of an app").TestDeliveryStrictOrder: three events; the receiver answers500twice to the first; the receiver log shows the second and third only after the first's2xx, inseqorder.TestDelivery410Disables(plan: "410disables"): rowfailedwithresponse_code410andlast_error = gone; subscriptiondisabledwithdisabled_at; one audit entry ofwebhook_out.transitionwith causegone; no further request; after a re-enable the next event goes out.TestDeliveryRetryAfter(plan: "429and503respectRetry-After"):429withRetry-Afterin seconds and503with an HTTP-date, each larger than the backoff:next_attempt_atis not earlier than theRetry-Aftertime and the job is scheduled at it (fake clock);consecutive_failuresstays unchanged; the same for502and504; aRetry-Afterbeyond the deadline is cut to it.TestDeliveryBackoffAndConsecutiveFailures:500answers: everynext_attempt_atlies in the backoff interval of Design note 7; atwebhook.max_consecutive_failures(small value from the test configuration) the subscription isdisabled, audited with causemax_consecutive_failures, the head stayspending; a re-enable sends the head first.TestDeliveryDeadAfterRetryMaxAge: with the fake clock pastcreated_at + webhook.retry_max_age, a failure makes the rowdeadand the subscriptiondisabled; after a re-enable the following event goes out.TestDeliveryPrivateAddressRefused(plan: "a private target address rejected"; Red Team "webhooks to private targets"): the fake resolver answershooks.example.comwith a loopback address and with a private address of RFC 1918, without anallow_privateentry: no connection reaches the receiver, the row stayspendingwithlast_error = address_blockedand a counted failure,bothy.egress.blockedrecordsaddress_blockedthrough the fake metrics of the egress client, and the log contains host and reason only. With the entryhooks.example.com:<port>;allow_privateinegress.allowed_hoststhe same delivery succeeds. A receiver answering302to another allowed host: the other host gets no request,last_error = http_status.TestDeliveryGapDisables: a paused subscription with pending rows; the fake clock and the partition maintenance of M1-16 drop the partition of the first event; after resume the subscription isdisabledwith causegap, every expired pending row isdeadwithevent_expired, the audit entry names the lost range; a second re-enable delivers the first retained event.TestConsumerLossDisablesSubscriptions: oneactiveand onepausedsubscription (the paused one with pending rows), and the consumer cursor behind a partition dropped through the fake clock and the partition maintenance of M1-16. After the round both subscriptions aredisabledwith causegap, their pending rows aredeadwithevent_expired, the audit entries name the lost range, and no row or job exists for the retained events of that round. A re-enable delivers the next new event in order and does not disable again.TestDeliveryQuotaPauses:webhook.max_pending_per_subscriptionsmall from the test configuration, the receiver blocks: the crossing round pauses the subscription with an audit entry of causemax_pending; rows keep being created; after resume no immediate new pause; the backlog drains in order.TestDeliverySuspendedInstallation: installationsuspended: rows are created, no request is sent, rows staypending;ResumeInstallationdelivers them in order.TestInstallCreatesManifestSubscriptionandTestUninstallDisablesSubscriptions(11 4.1, 4.3).TestRedeliverWebhook: adeadrow becomespendingwithcreated_atmoved, goes out with the samewebhook-id, endssucceeded; apendingrow in backoff goes out at once; an event no longer in the log gives409 conflictwithreason: event_expired; Alex gets404; the audit entry exists; the replay returns the stored202.TestDeliveryConcurrentJobs: two jobs of one subscription at once lead to exactly one request.TestDeliveryCrashAfterSend(plan "Test strategy": duplicate delivery): with a shortwebhook.delivery_timeoutfrom the test configuration, the record step fails once after a2xx(injected store error); River's retry finds the lease and ends; after the lease the lease wake-up job sends again; the receiver sees the samewebhook-idtwice; the row endssucceededonce.TestDeliveryDeletedDuringAttempt: a subscription deleted while the receiver holds the request: the result is discarded, no error.TestWebhookSecretsNeverLeak(SEC-SEC-3, SEC-T10): thewhsec_value, the master key bytes, the URL path and a message text sent through a delivery appear in no row ofwebhook_subscriptions,webhook_deliveries,idempotency_keysandaudit_events(row_to_json), in no captured log record, span attribute or error string. If M1-69's canary scenario exists, extend it with a webhook delivery and thewhsec_value instead of a separate test.- Route registration: AZ-P1 (M1-17, M1-25) passes with the seven enabled operations;
VerifyRegistrationsaccepts them now thatwebhook_out.deliveris no longer deferred.
There is no end-to-end test, no platform run and no contract test beyond G10: the compose smoke test of M1-73 keeps passing. Plan row test IDs: G10 (Tests "G10, part 1" and "G10, part 2"), AZ-M49 (TestAZM49); the plan row's other items map to TestDelivery410Disables, TestDeliveryRetryAfter, TestDeliveryPrivateAddressRefused and TestDeliveryRevokedRightIsDeadWithoutRequest.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/apps/... ./internal/authz/... ./internal/instance/... ./internal/sync/... ./internal/kernel/... ./internal/platform/... ./internal/wire/... ./migrations/... ./api/...
make migrations-lock
make migrations-lint
make codegen-check
make arch
make register-check
make api-lint
make api-checks
make api-tables
make config-reference
make config-doc-check
make adr-index
make gitleaks
make licenses
make lint
make docs
make check
After committing, git diff --exit-code docs/operations/configuration.md docs/adr/README.md docs/spec/06-api.md docs/api/webhooks.md shows nothing after make config-reference, make adr-index and make api-tables.
Acceptance criteria¶
- G10: the committed vectors reproduce byte for byte; a real delivery verifies with the Standard Webhooks reference library (
v1) and with Ed25519 of the standard library (v1a). -
410switches the subscription off (disabled, audited) and sends nothing more. -
429and503(and502,504) honourRetry-Afterand do not count as failures. - A target that resolves to a private address is never contacted (
address_blocked); redirects are not followed; onlyhttpshosts ofwebhook.allowed_hostsare accepted (400 webhook_host_not_allowed). - AZ-M49: an event from Support/Allgemein does not reach Tracker; the Eskalation event does.
- A revoked right or removed scope between round and job makes the delivery
deadwithnot_authorizedwithout a request. - Deliveries come from the log through the consumer
webhooksand the jobapps.webhook_deliver, strictly inseqorder per subscription, with backoff and full jitter,deadafterwebhook.retry_max_age,disabledafterwebhook.max_consecutive_failures, the gap rule and the quota rule. -
webhook_out.deliverdecides before every attempt (filter mode, no audit denial); its deferred entry is gone. -
v1aalways,v1per subscription with a derived secret that is never stored and shown once; rotation overlap works;getWebhookKeyspublishes the keys aswhpk_. -
createWebhookSubscription,updateWebhookSubscription,deleteWebhookSubscription,listWebhookSubscriptions,listWebhookDeliveries,redeliverWebhookandgetWebhookKeysare enabled; the two agent operations stay underlaterand are reported. - Tables match 03 4.11; install creates the manifest subscription, uninstall disables, suspension pauses.
- webhooks.md, 11, 02, openapi.yaml, configuration.md, signals.md, threat model, ADR 0016 and 0051, READMEs, DECISIONS and CHANGELOG are updated; generated references are current.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
apps(portWebhooks, tables, consumer, job and its triggers, states and statuses,last_errorcodes, retention rule of Design note 6, master key, test vectors),sync(LogReader,SyncEpoch,ConsumerLossHandler),instance(Signers,PublicKeys,getWebhookKeys) andauthz(requirement ofwebhook_out.deliver, deferred list) updated. - Rulings of Design note 18 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- ADR 0016 and ADR 0051 "Confirmation" name this ticket's tests without changing the decisions; a new ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN), with attention to the M2 "Red team" items "webhooks to private targets" and "exceeding the scopes of an app"; onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven (for example a receiver that verifies
v1awith a library other than the standard library, behaviour under real DNS). - Follow-ups that could not be done in this scope, each with its reason (agent subscriptions and their two operations,
suspendAppandresumeApp, the retention rule for M7-05), every difference between the M1 and M2 names in this brief and the merged code (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.