M2-27: Incoming webhooks native and alertmanager¶
Status: ready | Depends on: M2-05, M2-24 | Milestone: M2
Goal¶
After this ticket an external system writes into exactly one channel of the instance through an incoming webhook, without a session or an API token of its own. A person with manage on the channel creates the webhook for an installed app that has write there (step-up, audit) and receives its URL with a bothy_whi_ token exactly once; the server keeps only the HMAC. The sender posts either with Authorization: Bearer <token> or with the token as the last path segment, in the format native (Markdown with optional thread_id, title and client_msg_id) or with the unchanged webhook payload of Prometheus Alertmanager, which the server turns into one message per notification. The checks run in the order of 11 8.4 (rl.webhook_in, token, body size, format, authorize of the app), and every refusal of the token or of the app's rights looks like an unknown webhook (404). The message lands in the thread chosen by the rules of 11 8.3, carries origin = app with the app as author, resolves no mentions and never triggers an agent (AZ-M53, SR4). The path token appears in access logs, traces and Problem.instance only as the route pattern. A cron job that sends native with one client_msg_id per run gets exactly one message per run however often it retries (11 10.4), and the instance's own Alertmanager reaches the same routes on the internal listener (16 4.2), which M7-20 then configures. This builds the two built-in inbound formats of R-EX-08 and the cron and alarm paths of requirement 19 (11 section 10, PLAN block "reference integrations").
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy"; section 8 "M2 Agent platform, extensions and terminal": "Goal" (P-18: apps and webhooks use the same public API), "Scope" (11, webhooks of 06 anddocs/api/webhooks.md), "Exit criteria" 3 (AZ-M53), 9 (the cron path of M2-27 is green) and 10, "Test strategy" (fakes with fixtures from the vendor's public documentation, no real third-party service), "Red team" ("exceeding the scopes of an app", apps and webhook targets as trust boundaries); block "agents and extensions" rows M2-24 (apps, installations, scopes), M2-25, M2-26 (outgoing webhooks), M2-27 (this ticket) and M2-28; block "reference integrations" (introduction: cron messages over the incoming webhook, monitoring alarms over the formatalertmanager, no integration calls a real service in tests) and rows M2-49, M2-50; rows M1-69, M1-70, M6-08, M7-08 and M7-20; section 18.1 row 19m.- Dependencies (D-W-103): the header names M2-05 (the acceptance subscriber
runs.OnMessagePosted, needed to prove "no run" in AZ-M53) and M2-24 (tablesappsandapp_installations, app principals, installation, uninstall, the authorize facts of app principals). Implied through them and therefore not in the header: M1-19 (ratelimit.Limiter,rl.webhook_inkeyed bywebhook_id, failed-credential hook ofStageAuthenticate), M2-04, M2-02, M1-56 (createMessage,createThread,MessagePosted), M1-55 (threads), M1-54, M1-53 (channels,ChannelArchived), M1-44 (token format with kindwhi, HMAC withauth.token_pepper_ref), M1-35, M1-34, M1-32, M1-31, M1-29, M1-28, M1-25, M1-24, M1-20, M1-18, M1-17, M1-16, M1-13, M1-12, M1-11, M1-10 and the tickets before them; M1-62 (moveThread), M1-68 (bothy.message.created) and M1-69 (canary scenario) are in the closure as well (through M2-02). Used only if merged: M1-70 (leak and timing tests), M2-26. docs/spec/11-extensions.md: 1 "Principles" (point 4: the server never loads a URL from user data); 3.1 "Format" (rowincoming_webhooksof the manifest); 4.1 "Installation"; 4.3 "Suspension and uninstallation" (row "Grants, incoming webhooks": suspend keeps them, uninstall revokes them); 5 "Scopes and grants"; 6 "Isolation and limits" (row "Rate limits":rl.webhook_inper webhook; row "Never allowed"); 8 "Incoming webhooks" with 8.1 "Creation and call", 8.2 "Formats" (with the Alertmanager evidence), 8.3 "Target thread", 8.4 "Rules" (the flowchart is the order of checks); 10 "Ready-made integrations as examples" with 10.3 "Monitoring alerts" and 10.4 "Cron reports"; 14 "Decisions of this chapter" (R-EX-08, R-EX-09, R-EX-11); 16 "Configuration values of this chapter".docs/spec/03-data-model.md(core contract, read only):incoming_webhooks(all columns, keys, index, retentionconfig, the last sentence);api_tokens(the paragraph on the format with kindwhiinincoming_webhooks.token_hash);apps;app_installations(state,granted_scopes);messages(origin,client_msg_id,client_payload_sha256, the unique pair(author_principal_id, client_msg_id), the checktrigger_state IS NULL OR origin = 'human');threads(kind,parent_message_id, the unique index onparent_message_id);channels(kind,free_response,archived_at, the paragraph "Mention rule"); 6.3 "Lock order".docs/spec/04-sync-protocol.md: 5.1 "Messages:client_msg_id".docs/spec/05-actions.md: rules 1.1, 1.2, 1.3 and 1.9; 2.2 "Target of a resource"; 2.3 "Effective level" point 5; 2.4 (column Auditdenials, aggregation R-AZ-04); 2.5 "Decision"; 2.6 "Scopes for apps" (rowwebhook_in.post: no scope, token of the incoming webhook); 3 "Rate-limit classes" (rl.webhook_in,rl.public_authwith failed credentials); 4.11 "Apps and webhooks" rowswebhook_in.read,webhook_in.create,webhook_in.revoke,webhook_in.post; 5 "Agent invocationagent.invoke" point 1.docs/spec/06-api.md: 1 "Overview" (table of surfaces); 2 "Basic rules" (rows "No effect", "Nonull", "Unknown values", "Size"); 3 "Authentication per principal kind" (row "Incoming webhook"); 4 "Authorization in the API"; 5 "Errors" (point 1 oninstancewith{token}, points 3 and 7); 7 "Idempotency" (rows "Incoming webhook" and "POST with a one-time secret in the response (enrollment code, API token, later webhook secrets)", point 3 with the generated exemption table); 9 "Rate limits"; 12 "Webhooks"; 16 "Endpoints" (generated).docs/spec/07-permissions.md: 2.10 "Apps"; 4.1 "Decisions and reasons" (app_not_installed,no_grant,scope_missing,level_insufficient,channel_archived); 4.2 "Flow"; 4.5 "Archived spaces and channels"; "SR4 Only humans trigger"; 10 "Examples" (the world); 11.1 "Test matrix" (the world paragraph with G-6 for the app Tracker, row AZ-M53); 11.2 rows AZ-P1 and AZ-P4.docs/spec/08-identity.md: 12 "API tokens for agents, apps and tools" (form with kindwhi, storage as HMAC, the bullet "Incoming webhooks").docs/spec/02-architecture.md: 3.2 "Roles of a process" (row of the internal listener: incoming webhooks from the stack in rolesapiandworker); 4.3 "Ports, events and sources per module" (rowapps: portIncomingWebhooks, subscriptionchat.ChannelArchived); 4.4 "Allowed dependencies" (rowapps); 8.1 to 8.3; 9.1 "Chain in the api node"; 9.3 "Proof of the check".docs/spec/16-operations.md: 4.2 "Two listeners"; 4.3 "Edge and identity".docs/spec/19-security.md: SEC-IN-1, SEC-IN-2, SEC-IN-4 (6.1), SEC-OUT-1, SEC-OUT-2 (6.2), SEC-LOG-1 to SEC-LOG-3 (9), SEC-T10 (14).docs/api/openapi.yaml: operationslistIncomingWebhooks,createIncomingWebhook,revokeIncomingWebhook,postIncomingWebhook(withsecurity: [],x-idempotency-exempt: client_id); schemasIncomingWebhook,IncomingWebhookCreate,IncomingWebhookCreated,IncomingWebhookList,IncomingWebhookPost,IncomingWebhookResult,Problem(x-problem-codes, the description ofinstance),FieldError,JsonObject,MessageOrigin; parametersWebhookIdPath,TokenPath; responsesNotFound,PayloadTooLarge,TooManyRequests,UnprocessableEntity; headerIdempotent-Replay; security schemebearerAuth; for the test worldinstallApp,uninstallApp,suspendApp,resumeApp,putChannelBehavior,setSpaceOperator,createMessage,createThread,moveThread,archiveChannel,listChannelThreads.docs/api/webhooks.md: 8 "Incoming webhooks".- ADRs 0007 (migration numbers,
migrations.sha256), 0013, 0015, 0016, 0017, 0022, 0053, 0061. docs/DECISIONS.md: D-LS-06, D-EX-02, D-API-01, D-W-100, D-W-103; section 10 for the format of new rows.docs/security/threat-model.md: TB6, asset A5, table 5.11 "Apps and webhooks", T-TK-03 (theft of a webhook URL; tests AZ-M53 and SEC-T10), T-DS-01.docs/RISKS.md: RK-20 (token in the URL in proxy logs). No item ofdocs/OPEN-QUESTIONS.mdconcerns this ticket.docs/observability/alerting.md2 "Alert channel in the instance";infra/observability/alertmanager/alertmanager.yaml(receiverbothy-opswithurl_fileandsend_resolved);docs/observability/signals.mdrowsbothy.ratelimit.rejectedandbothy.message.created(attributebothy.message.origin).docs/operations/configuration.md:webhook_in.max_body_bytes,webhook_in.formats_enabled,ratelimit.rl.webhook_in.per_minute,ratelimit.rl.webhook_in.burst,ratelimit.rl.public_auth.per_minute,ratelimit.rl.public_auth.burst,message.max_content_bytes,thread.title_max_chars,identity.last_active_granularity,server.public_url,server.internal_listen_addr,server.trusted_proxies,http.max_json_body_bytes,sync.idempotency_key_ttl,database.tx_retry_max,audit.deny_aggregate_window,auth.token_pepper_ref,authz.not_found_timing_tolerance.- M1 briefs: M1-10 (access log with the route pattern,
TestLogsNeverContainSecretsHeadersOrRawPaths), M1-11 (httpserver.Internal, "later tickets may add stack-internal webhook routes"), M1-12 (pgtest,migrations/README.md), M1-13 (txn.Subscribe,txn.Publish,testdata/txn_registrations.golden), M1-17 (api/operations.yaml,routes.Operation), M1-18 (chain stages,(*Chain).Use, problem writer with{token}ininstance, body limit per route,StageValidateand itserrors[].codevocabulary,httpservertest), M1-19 (ratelimit.Limiter, keywebhook_idforrl.webhook_in, the failed-credential hook ofStageAuthenticate), M1-20 (idempotency.MarkOneTimeSecret), M1-21 (design note 1: the internal listener evaluates no forwarded headers), M1-24 (auditing.Recorder), M1-26 (kernel/textcheck), M1-28 (actor fieldsInstallationIDandGrantedScopes, requirement atomsAll,AppKind,Level, deferred entries and their golden list,authorize.Filter, the visibility row forincoming_webhook), M1-31 (response validator inservices/server/test/apitest), M1-44 (identity/domain/apitoken, the authenticator rejects kindwhi, one-time secret,last_used_atgranularity), M1-53 (ChannelArchived{ChannelID, SpaceID, ArchivedAt}), M1-55 (chat.Threads.Info, D1 parent rule per channel kind), M1-56 (D1 flow, D2 body rules, D3 request hash, D4 origin, D5 mentions, D7 events, D8createThread), M1-62 (moving a thread changes its channel). - M2 briefs:
docs/briefs/M2-24-apps-install.md(port and use cases ofapps),M2-02-channel-behavior-config.md(putChannelBehaviorwithfree_response),M2-05-runs-state-machine.md(OnMessagePosted, step 1: return unlessOrigin = human),M2-19-agent-messages.md(the same pattern fororigin = agent). - Code on the base branch:
internal/apps(port,domain,app,adaptersof M2-24),internal/messaging,internal/chat,internal/identity(Tokens,domain/apitoken, HMAC hasher),internal/authz,internal/kernel/{actor,authorize,auditing,errs,ids,idempotency,textcheck,txn,clock},internal/platform/{httpserver,ratelimit,config},api/routes,internal/wire.
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:
createIncomingWebhook(webhook_in.create:manageon the channel, step-up, Audityes,rl.admin,Idempotency-Key) creates a webhook for one channel that always writes as its app (incoming_webhooks.app_id) withwriteon the channel, optionally with a fixed thread. The answer carries the URL with the token exactly once; onlytoken_hash(HMAC-SHA-256 with the server pepper) is stored; a replay of the sameIdempotency-Keyanswers409 conflictwithreason: one_time_secret(11 8.1, 05 4.11, 03incoming_webhooks, 08 12, ADR 0061, 06 7).- A call is a
POSTwithAuthorization: Bearer <token>or with the token as the last path segment. An unknown webhook, a wrong token and a revoked webhook give the same404(11 8.1, R-EX-09, 06 1 and 3). - Order of checks (11 8.4):
rl.webhook_in(429 rate_limited), token (404 not_found), body up towebhook_in.max_body_bytes(413 payload_too_large), format switched on and valid (400 validation_failed),authorizeof the app (404 not_found), then the message and201withmessage_id. - Formats
nativeandalertmanageronly, switched bywebhook_in.formats_enabled(11 8.2, R-EX-08).nativewithclient_msg_idis idempotent: a repeat answers200with the original andIdempotent-Replay: true, other content422 idempotency_key_reuse(06 7, 04 5.1).alertmanagergives one message per notification with state, count,alertname, a summary per alert from labels and annotations, andgeneratorURLandexternalURLas text (11 8.2). - Target thread: the fixed thread of the webhook, else
thread_idof the body when the thread belongs to the channel, else a new thread withtitle(required in forums), else the main thread of the text channel (11 8.3). - The message has
origin = appand the app as author, resolves no mentions and never triggers an agent; content up tomessage.max_content_bytes; no attachments on this path (11 8.4, 03incoming_webhooks, 07 2.10 and SR4, 05 5 point 1, R-EX-09; AZ-M53). - Rate-limit class
rl.webhook_inper webhook. The path token appears in access logs, traces andProblem.instanceonly as the route pattern/api/v1/hooks/{webhook_id}/{token}(05 3, 06 5 point 1, 19 SEC-LOG-3, 08 12). listIncomingWebhooks(webhook_in.read,manage, never a token) andrevokeIncomingWebhook(webhook_in.revoke,manage, step-up, Audityes; a second revoke has no effect, 06 2 "No effect") (05 4.11).- Uninstalling the app revokes its incoming webhooks; suspending keeps them (11 4.3), and a post is refused while the installation is not
active(07 4.1app_not_installed). - Senders inside the stack (Alertmanager) reach the same routes on the internal listener, in every role (16 4.2, 02 3.2).
- No JSON
nullin answers; every answer validates againstopenapi.yaml(ADR 0053).
Scope¶
All paths under services/server/ unless they start with docs/ or infra/. Names of M1 and M2 code below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
migrations/<next free number>_apps_incoming_webhooks.sql: tableincoming_webhooksexactly as 03: all columns with types, nullability and defaults; primary keyid; foreign keysapp_id -> apps(principal_id),channel_id -> channels(id),thread_id -> threads(id),created_by -> principals(id), eachON DELETE RESTRICT; uniquetoken_hash; index(channel_id) WHERE revoked_at IS NULL; privileges for the application role asmigrations/README.md(M1-12) prescribes; Down part for development. Thenmake migrations-lock.internal/apps/domain/incoming/(or the domain package layout M2-24 chose forapps):format.go(Classify, Design note 4),native.go(body rules),alertmanager.go(ParseAlertmanager,RenderAlertmanager(n Notification, maxBytes int) string, Design note 7),target.go(ChooseTarget, a pure function over the facts of Design note 5),hashctx.go(Design note 6c);testdata/alertmanager/*.jsonwith the expected*.md.internal/apps/app/incoming.go: use casesListIncoming,CreateIncoming,RevokeIncoming,AuthenticateIncoming,PostIncoming; the subscriberOnChannelArchived(ctx context.Context, ev chat.ChannelArchived) error;revokeIncomingForAppfor the uninstall use case.internal/apps/adapters/postgres/queries/incoming_webhooks.sqland the regenerated sqlc code:ListActiveIncomingByChannel,InsertIncoming,GetIncomingForAuth(webhook row joined with the app's installation that is notuninstalled),LockIncomingForPost(FOR SHARE),RevokeIncoming,RevokeIncomingByApp,RevokeIncomingByChannel,TouchIncomingLastUsed(conditional onidentity.last_active_granularity).internal/apps/adapters/httpapi/incoming.go: strict handlers forlistIncomingWebhooks,createIncomingWebhook,revokeIncomingWebhook,postIncomingWebhookandpostIncomingWebhookWithBearer;incoming_stage.go: the middleware for the new chain stage (Design note 2).test/integrations/(new folder underservices/server/test/):cron_path_test.go,alertmanager_path_test.go,azm53_test.go, end to end through the real server assembly ofinternal/wire; register the folder inarchitecture/modules.yamlthe way the other folders undertest/are registered, ifmake archasks for it (M1-05).internal/wire/configkeys_apps.go(extend it if M2-24 or M2-26 created it):webhook_in.max_body_bytes(KindInt, minimum 1) andwebhook_in.formats_enabled(KindStringList), defaults from the 11 table, chapter constant for11-extensions.mdinconfigchapters.goif missing; a start check inconfigchecks.go: every entry ofwebhook_in.formats_enabledisnativeoralertmanagerand appears once, else a configuration error naming the key.
Change:
internal/appsport: interfaceIncomingWebhooks(02 4.3) with the use cases above and their DTOs; doc comments.internal/apps/appuninstall use case of M2-24: revoke all active incoming webhooks of the app in the same unit of work (Design note 11).internal/identityportTokens(M1-44):NewIncomingWebhookToken(ctx context.Context) (IncomingWebhookToken, error)returning the secret in the redacting secret type of M1-44 plus its hash, andHashIncomingWebhookToken(ctx context.Context, raw string) ([]byte, error)returningErrTokenMalformedwithout any database call for every value that is not a well-formed token of kindwhi(format, checksum, kind). Both usedomain/apitokenand the HMAC hasher withauth.token_pepper_ref; tests inidentity. The authenticator keeps rejectingwhieverywhere else (M1-44).internal/messagingport: the create command ofMessages(and the thread-with-first-message command ofcreateThread) getsSkipMentions boolandHashContext string; zero values keep the M1-56 behaviour. WithSkipMentionsthe mention scanner does not run, nomentionsrow is written andMessagePosted.MentionedIDsis empty;HashContext, when set, replaces the context part of the request hash of M1-56 D3. Tests inmessaging.internal/authz/domain/requirements.go: the entry ofwebhook_in.postchanges fromDeferredtoAll(AppKind, Level(write))(Design note 3); remove it from the golden list of deferred entries;internal/authz/README.md.internal/platform/httpserver: a new stage constantStageWebhookTokendirectly afterStageRateLimitand beforeStageMaintenance; the golden test of the chain order; an override of the body limit ofStageRouteper operation, used by the two webhook operations withwebhook_in.max_body_bytes;README.md.internal/wire:StageAuthenticateleaves requests of operations with actionwebhook_in.postanonymous (Design note 2); installs the apps middleware atStageWebhookToken; registerschat.ChannelArchived -> apps.OnChannelArchivedafter the existing subscribers and updatestestdata/txn_registrations.golden; mounts the two webhook operations on the internal listener in every role (Design note 12); binds the new configuration keys and the port.api/operations.yaml: movelistIncomingWebhooks,createIncomingWebhook,revokeIncomingWebhookandpostIncomingWebhookfromlatertoenabledand addpostIncomingWebhookWithBearertoenabled(sorted); regenerate the server code (M1-17).docs/api/openapi.yaml,docs/api/webhooks.md,docs/spec/11-extensions.md,docs/spec/06-api.md,docs/spec/02-architecture.md,docs/spec/16-operations.md,docs/observability/alerting.md,docs/security/threat-model.md: as Design note 15 lists; regenerate the tables of 06 withmake api-tablesand the configuration reference withmake config-reference.- READMEs of
apps,messaging,identity,authzandinternal/platform/httpserver;services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M2-27-<n>for every ruling of Design note 16.
This ticket creates no new top-level make target, no durable or ephemeral event type, no job and no metric. It creates one operation (postIncomingWebhookWithBearer) and one schema (AlertmanagerAlert), both by ruling (Design note 16).
Out of scope¶
- Outgoing webhooks,
webhook_subscriptions,webhook_deliveries, the jobapps.webhook_deliver,getWebhookKeys, G10 and AZ-M49 (M2-26). - Manifest, installation, scopes, app tokens and AZ-M32 to AZ-M34 (M2-24); this ticket only extends its uninstall use case.
- The operations
suspendAppandresumeApp: no plan row names them (M1-17 lists them for M2 without a ticket; plan gap reported). Design note 11 fixes what a suspended installation means for posts. - The one-step "incoming app" of 11 8.1 (an owner creates a minimal app together with the webhook):
IncomingWebhookCreatehas no member for it and no plan row owns it (spec gap reported). Until then an owner installs a minimal app throughinstallApp(M2-24) first. - Commands and interactions of apps (M2-25); the reference apps for code hosting and ticket systems, which receive their webhooks at their own address (M2-49, M2-50).
- One thread per alert group or any mapping from an external key to a thread (11 8.3: an app's task, or a later table proposed in a report).
- The Alertmanager configuration of the stack, the alert channel of the instance, the test alarm and the watchdog (M7-20).
- Console screens for incoming webhooks (M6-08).
- Notifications and push for webhook messages (M3-17 to M3-19); search of their content (M3-13).
- The canary scenario across all outputs (M1-69, in the closure; Tests say how this ticket extends it); leak and timing tests over all routes (M1-70); maintenance mode (M7-08):
webhook_in.posthas effectwrite, so it answers503 maintenancethere. - The trigger path of agents (M2-04, M2-05, M2-08): only used to prove that nothing triggers.
- 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-27-webhooks-incoming; 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>. - The same holds for M2-02, M2-05 and M2-24: their merged names win over the expected names here; list each difference.
- No real Alertmanager, Prometheus or other third-party service in tests: the Alertmanager payloads are fixtures built from the structure documented in 11 8.2 (evidence retrieved 2026-10-10) with white-label values. No model call of any kind.
- Tokens never appear in logs, problem bodies, audit details, event payloads or test failure messages; tests build tokens at runtime, and every committed token-shaped value (openapi examples, fixtures) carries the marker
EXAMPLEin its random part (CONTRIBUTING.md, M1-44). - Time only through
kernel/clock; tests move a fake clock and never sleep for a window. - A mutation used to prove a red run is never committed.
Design notes¶
-
Start check.
make checkis green on the fresh worktree, elseVerdict: BLOCKED base branch red. Check before writing code that the base has (all are in the dependency closure; the check guards against a capability the merged code lacks, while a differing name follows Boundaries):internal/platform/ratelimitwithrl.webhook_inkeyed by the path valuewebhook_idand the failed-credential hook (M1-19); the tableschannelsandthreads(M1-53, M1-55);createMessage,createThreadandMessagePosted(M1-56);appsandapp_installationswith an uninstall use case (M2-24); the acceptance subscriberruns.OnMessagePosted(M2-05). A missing one ends the ticket withVerdict: BLOCKED dependency <id> missing. -
Routes and order of checks. Two operations serve posts, with the same action
webhook_in.post, classrl.webhook_in, exemptionclient_idand request body:postIncomingWebhookonPOST /api/v1/hooks/{webhook_id}/{token}(security: []) andpostIncomingWebhookWithBeareronPOST /api/v1/hooks/{webhook_id}(security: bearerAuth). Ruling for docs/DECISIONS.md: the Bearer variant of 11 8.1 and 10.3 is its own operation, because one operation cannot have two paths and the plan row asks for both. A request passes, in this order, which is the order of 11 8.4 placed into the chain of 02 9.1: a. Stages 1 to 6 as for every route (M1-18).StageRoutesets the body limit of these two operations towebhook_in.max_body_bytesinstead ofhttp.max_json_body_bytes. b.StageAuthenticate: for every operation whose action iswebhook_in.postthe identity authenticator is not called and the actor stays anonymous, whateverAuthorizationholds (the token of these routes is not an API token; M1-44 would reject kindwhiwith401). c.StageRateLimit:rl.webhook_inkeyed by the path valuewebhook_id(M1-19); exhausted gives429 rate_limitedwithRetry-After, before any database access and for unknown IDs too. d.StageWebhookToken(new, Design note 2): token check; failure gives404 not_found. e.StageMaintenance,StageIdempotency(the exemptionclient_idskips it),StageBody(413 payload_too_largenamingwebhook_in.max_body_bytes,400 malformed_request),StagePreconditions,StageValidate(400 validation_failed, unknown members included),StageProof. f. Handler: classification and format switch (400 validation_failed, Design note 4), body rules per format, target thread (Design note 5),authorize(Design note 3, every denial gives404 not_found), write (Design note 6),201withIncomingWebhookResult, or200withIdempotent-Replay: trueon a replay. Ruling for docs/DECISIONS.md: the token check is a chain stage between the rate limit and the body stages, so it runs afterrl.webhook_inand before any body is read or validated, exactly as 11 8.4 orders it; 02 9.1 gets the new step. -
Token check (
StageWebhookToken, middleware ininternal/apps/adapters/httpapi, installed byinternal/wire). It acts only on operations with actionwebhook_in.post; others pass through. a. Token source:postIncomingWebhooktakes the path valuetokenand ignoresAuthorization;postIncomingWebhookWithBearertakesAuthorizationand accepts exactly the schemeBearerwith one token. A missing or other scheme counts as a wrong token. b.identity.Tokens.HashIncomingWebhookToken: a malformed value fails without a database call. OtherwiseAuthenticateIncomingloads the webhook by the path valuewebhook_idwith its app's installation that is notuninstalled(GetIncomingForAuth) and comparestoken_hashwithhmac.Equal. Unknown ID, wrong hash,revoked_atset, no installation or an installation in statesuspendedall give the same404 not_found(11 8.1, 4.3; 07 4.1app_not_installed). A known and an unknown ID take the same path (one HMAC, one indexed lookup), so AZ-P4 timing holds. c. A failed check is a failed credential (02 9.1, 05 3): it takes one token fromrl.public_authof the client IP through the hook M1-19 gaveStageAuthenticate. At the start of the stage, while that bucket of the client IP is empty, the stage answers429 rate_limitedwithout checking the token, asStageAuthenticatedoes for other credentials. A failed check writes no audit entry (it is authentication, not anauthorizedecision). d. Success replaces the anonymous actor with the webhook actor (Design note 3) and stores the verified webhook ID for the handler. The handler uses the path value only after checking it equals the stored ID. -
Webhook actor and rights. Ruling for docs/DECISIONS.md: the actor of a verified post is the app principal (
ID=incoming_webhooks.app_id,Kindapp) withInstallationIDof its active installation,GrantedScopes= the installation'sgranted_scopesintersected withmessages:write, and noTokenID,SessionIDorDeviceID(the fields of M1-28; M2-24 fills the same fields for app tokens). Reason: a stolen URL may only post into its channel (T-TK-03);message.createandthread.createneed exactlymessages:write(05 2.6). a. Ruling for docs/DECISIONS.md: the requirement ofwebhook_in.postisAll(AppKind, Level(write))on the resource{kind: channel, id: incoming_webhooks.channel_id}; it needs no scope (05 2.6, already in M1-28's scope table). An anonymous principal getsunauthenticated(fail closed; 07 4.2 step 1 allows anonymous only for the rightpublic, and this right is "public with token; the app needs write"). b. The use case callsauthorize(webhookActor, webhook_in.post, channel)on every post, also on a replay (02 9.3 proof). Then the messaging use case authorizesmessage.create(andthread.createfor a new thread) for the same actor, which brings the scope check, the read-only check (05 rule 1.8) and the archive rule (07 4.5). c. Every outcome other thanallowof these three checks (not_found,forbiddenwith any reason includingscope_missing,channel_read_onlyandchannel_archived,unauthenticated) becomes404 not_foundwith exactly the body and headers of an unknown webhook (11 8.4: decision "authorize app: message.create on the channel", branch "no" to404). Internal errors stay500 internal_error. d. Audit: denials ofwebhook_in.postare written by the authz layer (Auditdenials, aggregated peraudit.deny_aggregate_window, R-AZ-04);message.createhas Auditno; an allowed post writes no audit entry. -
Formats and body rules. Ruling for docs/DECISIONS.md: the format is decided per request from the body, not stored per webhook. Reason: 03
incoming_webhookshas no format column (core contract), openapi has no format member, and one closed request schema must cover both shapes becauserule/oneof-needs-discriminatorforbids aoneOfwithout discriminator and the Alertmanager payload has no discriminator member. The request body stays one schema,IncomingWebhookPost, with the native members (content_md,title,thread_id,client_msg_id) and the Alertmanager members (version,groupKey,truncatedAlerts,status,receiver,groupLabels,commonLabels,commonAnnotations,externalURL,alerts) and norequiredlist. a.Classify: a body with at least one Alertmanager member isalertmanager; it must contain no native member, else400 validation_failedwith the pointer of the first native member anderrors[].codeformat_mixed. Every other body isnative(8.2:nativeis the standard). b. A format not inwebhook_in.formats_enabledgives400 validation_failedwith an empty pointer anderrors[].codeformat_disabled. c.native(M1-56 D2 rules, pointers into this body):content_mdrequired (/content_md,required) with at least one non-white-space character (empty); more thanmessage.max_content_bytesUTF-8 bytes gives413 payload_too_largewithdetailnamingmessage.max_content_bytes;client_msg_id, when present, a UUIDv7 (/client_msg_id,not_uuid_v7), when absent a new server UUIDv7 (03messages.client_msg_id);titlefollows the title rules of M1-55 (/title);thread_idis a UUID (schema). Content is stored byte for byte. d.alertmanager(ParseAlertmanager):versionmust be4(schema enum),statusand eachalerts[].statusfiringorresolved(schema enum),alertsat least one item (schema), each alert needsstatusandlabels(/alerts/<i>/labels,required); every value insidegroupLabels,commonLabels,commonAnnotations,labelsandannotationsmust be a string (pointer to the member,invalid_type). The client message ID is always a new server UUIDv7: retries of Alertmanager are not deduplicated (8.2 makes onlyclient_msg_idofnativeidempotent). e. Unknown members give400 validation_failedwithunknown_memberinStageValidate(SEC-IN-1, W7); the label maps useJsonObject, which M1-18 exempts from the closed-schema check. -
Target thread (
ChooseTarget, 11 8.3). Facts: the webhook (channel_id,thread_id), the channel kind and its main thread, andchat.Threads.Infoof a fixed or named thread. In this order:- Fixed thread: used while it belongs to the webhook's channel;
thread_idandtitleof the body are ignored. Ruling for docs/DECISIONS.md: when the fixed thread was moved to another channel (M1-62), the post gives404 not_found, because a webhook writes only into its own channel (11 8.1 "for a channel", T-TK-03). thread_idof the body (native only) when that thread belongs to the webhook's channel; an unknown thread or one of another channel falls through to the next rule, so nothing about other channels is revealed.title(native only) creates a new thread. Forum: a forum post through thecreateThreaduse case with the message as its first message (M1-56 D8). Text channel: ruling for docs/DECISIONS.md: the message is written into the main thread and the new thread starts from it (parent_message_id= the message), likeauto_threadof 09 3.7; reason: in a text channel a thread grows from a message (01 4, M1-55 D1 requires the parent), and 8.2 says one message per call. The answer names the new thread inthread_id.- Main thread of a text channel. In a forum without rule 1 to 3:
400 validation_failed, pointer/title,errors[].coderequired_for_forum_post; foralertmanagerthe pointer is empty (10.3: Alertmanager posts into a text channel or a fixed thread).
- Fixed thread: used while it belongs to the webhook's channel;
-
Writing (
PostIncoming, one unit of work). a. Lock the webhook row withFOR SHARE(LockIncomingForPost) and check againrevoked_at IS NULLand that the actor is its app; else404 not_found. A revoke, an uninstall and an archive update the row and therefore wait for posts in flight; after their commit no post writes (Design note 10). Deadlocks are retried by the unit of work (database.tx_retry_max). b. Choose the target (Design note 5), callauthorize(Design note 3), then the messaging port with the webhook actor in the context,SkipMentions = true,Silent = false(notifications follow the channel settings, 11 8.4), no reply, no attachments,kind = text:createMessagefor rules 1, 2 and 4;createThreadwith first message for rule 3 in a forum;createMessageinto the main thread followed bycreateThreadwithparent_message_idof that message for rule 3 in a text channel. c. Ruling for docs/DECISIONS.md:HashContext="webhook:" + webhook_id + "|thread:" + <thread_id of the body or empty> + "|title:" + <title or empty>, so the hash covers the request as sent (04 5.1 point 3) and aclient_msg_idrepeated through another webhook of the same app gives422 idempotency_key_reuseinstead of a replay of a message of another webhook. A replay (same author,client_msg_idand hash) answers200withIdempotent-Replay: true, the originalmessage_idand the thread of the first answer (the thread whoseparent_message_idis the message in the text-channel case), and publishes nothing. The messaging port tells the caller that its answer is a replay (M1-56 D1 step 5); on a replay the use case creates no thread. d. Post-condition: thechannel_idof the written message equals the webhook'schannel_id, else roll back and answer404 not_found(a thread moved concurrently). e.MessagePostedcarriesOrigin = appandAuthorKind = app;runs.OnMessagePostedreturns at its first step (M2-05), so no run, no anchor, notrigger_state(SR4). Events are those of M1-56 D7 (message.createdwithactor {id, kind: app};thread.createdwhen a thread is created), in its order. f. After the commit,TouchIncomingLastUsedsetslast_used_atonly when it is empty or older thanidentity.last_active_granularity(ruling for docs/DECISIONS.md: the same rule asapi_tokens.last_used_at, 03); outside the unit of work so concurrent posts never upgrade their share lock; a failure is logged without the token and never fails the request. -
Rendering of an Alertmanager notification (
RenderAlertmanager, golden tests). Ruling for docs/DECISIONS.md: the layout below, so the expected message of a payload is fixed by test (11 8.2 lists the contents, not the layout;docs/observability/alerting.md2 adds severity and runbook link). a. Header line**FIRING** <title> (<n> alerts)or**RESOLVED** ...from the top-levelstatus;<title>iscommonLabels.alertname, elsegroupLabels.alertname, elseAlerts;<n>is the number of items inalertsplustruncatedAlerts, written1 alertfor one. b. A blank line, then one list item per alert in payload order:- **<firing|resolved>** <alertname>, severity <severity>: <summary>, where<alertname>is the alert's labelalertname(else the title),, severity <severity>appears only with the labelseverity, and<summary>is the annotationsummary, elsedescription, else the remaining labels exceptalertnameandseverityasname=valuesorted by name and joined by,, elseno summary. Continuation lines indented by two spaces:Runbook: <URL>from the annotationrunbook_urlandSource: <URL>fromgeneratorURL. c. WhentruncatedAlertsis above zero or items were dropped (d), a last item- and <k> more alerts. d. A blank line andAlertmanager: <URL>fromexternalURL. e. Values from the payload: line breaks and other control characters become one space, then every ASCII punctuation character is escaped with a backslash (CommonMark), so a label cannot add formatting, links or images. A URL is written only when it is an absolutehttporhttpsURL with a host and without white space,<or>, as an autolink<URL>whose visible text is the target itself (SEC-OUT-1); otherwise its line is left out. The server never fetches any of these URLs (11 1 point 4, SEC-OUT-2). f. When the text exceedsmessage.max_content_bytes: drop alert items from the end (counting them into the last item of c), then the footer, then cut the rest at a UTF-8 boundary so that...still fits. The result is never larger thanmessage.max_content_bytesand always valid UTF-8. -
createIncomingWebhook(POST /channels/{channel_id}/incoming-webhooks). a. Load the channel (unknown gives404);authorize(actor, webhook_in.create, channel): invisible gives404 not_foundidentical to an unknown channel, read withoutmanagegives403 forbidden, no fresh step-up gives401 step_up_required; tokens of agents and apps cannot do step-up (R-AZ-03). b. Body (IncomingWebhookCreate):namenon-empty after trimming, without control or bidi characters (kernel/textcheck, SEC-IN-4;/name);app_idan app principal with an installation in stateactive, else400 validation_failedwith/app_idanderrors[].codeapp_not_installed; the app must be allowedmessage.createon the channel, checked with the webhook actor of Design note 3 in filter mode (authorize.Filter, no audit), else/app_idwithapp_cannot_write(11 8.1: it always writes as an app withwriteon the channel; a manager sees the same through "Who has access?");thread_id, when present, a thread of this channel (chat.Threads.Info), else/thread_idwithnot_in_channel, the same for an unknown ID. Ruling for docs/DECISIONS.md: these three codes. c. Unit of work:identity.Tokens.NewIncomingWebhookToken; insert the row withcreated_by= the actor; audit allowwebhook_in.createon the channel with details{webhook_id, app_id, thread_id}(no name, never token or hash);idempotency.MarkOneTimeSecret(ctx)(M1-20). d.201withIncomingWebhookCreated:webhookandurl=server.public_urljoined with/api/v1/hooks/{webhook_id}/{token}(ruling for docs/DECISIONS.md: absolute, as the schema says "Full URL"; Bearer senders drop the last segment and send the token in the header). No event (05 section 6 has none for incoming webhooks), no rights change. -
listIncomingWebhooks: load the channel;authorize(actor, webhook_in.read, channel)(404without read right,403 forbiddenwithoutmanage); the active webhooks of the channel ordered bycreated_at, thenid, asIncomingWebhookwithout token or hash. Ruling for docs/DECISIONS.md: revoked webhooks are not listed (the partial index of 03 serves exactly this list; their history is in the audit log). -
revokeIncomingWebhook: load the webhook (unknown gives404); resource{kind: incoming_webhook, id, ChannelID}(M1-28 visibility row);authorize(actor, webhook_in.revoke, ...); already revoked:204and an audit allow entry withno_change: true(06 2 "No effect"); otherwiseUPDATE ... SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL, audit allow with{webhook_id, app_id},204. The next post with its token gives404 not_found. -
Lifecycle of the app and the channel. a. Uninstall (M2-24 use case, 11 4.3): in the same unit of work
RevokeIncomingByAppsetsrevoked_aton every active webhook of the app; the audit entry ofapp.uninstallstays the record. b. Suspend (11 4.3: "remain"): the rows stay; posts give404 not_foundwhile the installation is notactive(Design note 2b); afterapp.resumethe same URL works again. Ruling for docs/DECISIONS.md: suspend is an emergency stop for everything the app writes (11 4.3, R-EX-05, 07 4.1app_not_installed). c. Ruling for docs/DECISIONS.md:apps.OnChannelArchived(the subscription that 02 4.3 lists forapps) setsrevoked_aton the active webhooks of the archived channel in the archive transaction, because archiving is irreversible (05 has no unarchive) and such a webhook could never post again. No audit entry of its own; the archive is audited. -
Internal listener (16 4.2, 02 3.2,
docs/observability/alerting.md2 point 3). In every role (api,worker,all)internal/wiremounts the two post operations on the internal mux of M1-11 through the same chain as on the public listener, built for this mux; nothing else of the API is mounted there. On the internal listener the client IP is the peer address and forwarded headers are never read (R-SE-09, M1-21 design note 1). Ruling for docs/DECISIONS.md: both post variants are served there with identical behaviour; health stays as it is. -
Logs, traces and redaction. The access log and spans carry the route pattern (M1-10); the problem writer writes
instancewith the literal{token}(M1-18);Authorizationis never logged (SEC-LOG-2). The use cases never log a token, a hash, the content or the name.docs/spec/16-operations.md4.3 gets the rule that reverse proxies redact the path after/api/v1/hooks/{webhook_id}/in their logs (RK-20,docs/api/webhooks.md8 point 5). -
Configuration.
webhook_in.max_body_bytes(body limit of the two post operations),webhook_in.formats_enabled(Design note 4b, start check of its values),message.max_content_bytes(native content, rendering limit),ratelimit.rl.webhook_in.*andratelimit.rl.public_auth.*(registered by M1-19),identity.last_active_granularity,server.public_url,server.internal_listen_addr. Code comments and docs name the keys, never the values. -
Specification and API changes (same change, spec first; none touches the core contract): a.
docs/api/openapi.yaml:IncomingWebhookPostas Design note 4 (members of both formats,thread_idadded, norequired, a description of both shapes and of the classification, two request examples); new schemaAlertmanagerAlert(closed:statusenumfiring,resolved;labelsandannotationsasJsonObject;startsAt,endsAtasstringwithformat: date-time;generatorURL,fingerprintasstring); top-levelversion(string, enum4),groupKey,receiver,externalURL(string),truncatedAlerts(integer, minimum 0),status(enum),groupLabels,commonLabels,commonAnnotations(JsonObject),alerts(array ofAlertmanagerAlert, at least one item);postIncomingWebhookgets the responses200(replay withIdempotent-Replay) and422and a description of the order of checks and of the classification; new operationpostIncomingWebhookWithBeareras Design note 1 with the same request body and responses andsecurity: [bearerAuth: []]; the description ofbearerAuthnames incoming webhook tokens on that operation only;IncomingWebhookCreatedescribes the three rules of Design note 8b;IncomingWebhookCreated.url"absolute URL fromserver.public_url";IncomingWebhookResult.thread_iddescribes the text-channel case of Design note 5;IncomingWebhookList"active webhooks only"; the examples ofTokenPathandIncomingWebhookCreated.urlbecome token-shapedbothy_whi_values with the markerEXAMPLEand a valid checksum (ADR 0061).make api-lintstays green. b.docs/api/webhooks.md8: both call variants, the classification and the two formats, the target thread rules, the replay answer, the internal listener for senders in the stack, and a cron example that creates oneclient_msg_idper run and reuses it on retries. c.docs/spec/11-extensions.md(English, like the chapter): 8.1 the Bearer pathPOST /api/v1/hooks/{webhook_id}; 8.2 the member names of openapi (content_md,title,thread_id,client_msg_id), the classification per request, the layout rule of Design note 7 by reference todocs/DECISIONS.md; 8.3 the rules of Design note 5 (fixed thread only in the own channel, text channel with title); 8.4 the flowchart and text with the token check as chain step afterrl.webhook_in, every denial404, and the internal listener. d.docs/spec/06-api.md: 1 table with both paths; 3 row "Incoming webhook" with token in the path orAuthorization: Bearer; 5 point 1 stays (only{token}ofpostIncomingWebhookis a secret path parameter); the generated tables throughmake api-tables. e.docs/spec/02-architecture.md9.1: step 7 notes that operations with actionwebhook_in.poststay anonymous; a new step after the rate limit:appschecks the token of the incoming webhook (11 8.4), failure404and one token fromrl.public_auth. f.docs/spec/16-operations.md: 4.2 both post paths on the internal listener in every role; 4.3 the redaction rule of Design note 13. g.docs/observability/alerting.md2 point 2: the format is recognised per request; the URL ofcreateIncomingWebhookserves both formats; Alertmanager in the stack uses the same path onserver.internal_listen_addr. h.docs/security/threat-model.mdT-TK-03: add the test names of this ticket to the tests column. -
Rulings for
docs/DECISIONS.md(section 10, one rowD-M2-27-<n>each with reason): the Bearer operation (note 1); the token check as chain stage after the rate limit (note 1); the webhook actor and its scopes (note 3); the requirement ofwebhook_in.postand the uniform404(note 3); classification per request with one closed schema,format_mixedandformat_disabled(note 4); the fixed thread only in its channel (note 5); the text-channel title rule (note 5); the hash context (note 6c);last_used_atgranularity (note 6f); the Alertmanager layout, escaping, URL and truncation rules (note 7);app_not_installed,app_cannot_writeandnot_in_channelat creation and the absolute URL (note 8); active webhooks only in the list (note 9); suspend refuses posts (note 11b); revocation on channel archive (note 11c); both variants on the internal listener (note 12). The uniform404,origin = app, no mentions and no attachments are already in 11 and 03.
Tests (write them first)¶
Unit:
internal/apps/domain/incoming:TestClassify(native, Alertmanager, a mixed body with the pointer of the first native member, empty object is native);TestNativeRules(empty and white-space content,client_msg_idof version 4 rejected, absentclient_msg_idgives a server UUIDv7);TestParseAlertmanager(missinglabels, a label value that is a number, an emptyalertslist);TestRenderAlertmanager_Goldenovertestdata/alertmanager: firing with two alerts of onealertnamewith severity, summary, runbook and source; resolved with one alert; mixed alert names withoutcommonLabels.alertname;truncatedAlertsabove zero; ajavascript:URL and a relative URL left out; a label value with*,[, a line break and@alexescaped; nosummarybutdescription; neither, so the remaining labels are listed;TestRenderAlertmanager_FitsMaxContentBytes(with a small limit: items dropped from the end and counted, then the footer, then a cut at a UTF-8 boundary ending in...; output never above the limit, always valid UTF-8);TestChooseTarget(table: fixed thread in the channel, fixed thread moved away, body thread of the channel, body thread of another channel falls through, unknown body thread falls through, title in a forum, title in a text channel, nothing in a text channel, nothing in a forum for both formats);TestHashContext(stable, differs per webhook, per body thread and per title).FuzzRenderAlertmanagerandFuzzClassifywith the golden fixtures as seed corpus (the corpus runs ingo test): no panic, output withinmessage.max_content_bytes, valid UTF-8, no unescaped[from a label value.internal/identity:TestIncomingWebhookToken_MintAndHash(kindwhi, length and checksum per ADR 0061, hash equals the HMAC of M1-34 with the pepper, the secret prints[redacted]);TestHashIncomingWebhookToken_RejectsWithoutLookup(wrong checksum, kindagt,apporpat, wrong length:ErrTokenMalformed, repository fake counts zero calls); the authenticator test of M1-44 forwhistays green.internal/messaging:TestCreate_SkipMentions(no scanner run, nomentionsrow, emptyMentionedIDs, content unchanged);TestCreate_HashContextOverride(same request with another context gives422 idempotency_key_reuse; zero value keeps M1-56 D3).internal/authz:TestWebhookInPostRequirement: app withwriteon the channel givesallow; app withreadonly givesforbiddenlevel_insufficient; app without grant givesnot_found; anonymous givesunauthenticated; a person withwritegivesforbiddenwith the reason of theAppKindatom of M1-28 (subject_kind_not_allowed); the golden list of deferred entries no longer nameswebhook_in.post.internal/apps/app:TestWebhookActor_ScopesLimitedToMessagesWrite: for an installation grantedmessages:writeandmessages:read, the webhook actor carriesmessages:writeonly, soauthorizeofmessage.readfor it givesforbiddenscope_missing, whilemessage.creategivesallow(Red Team "exceeding the scopes of an app").internal/platform/httpserver: the golden chain order withStageWebhookTokenafterStageRateLimit;TestBodyLimitOverride(the override wins only for its operations anddetailnames its key).internal/wire:TestConfigCheck_FormatsEnabled(unknown value and duplicate fail the start with the key in the message; empty list allowed); the golden of subscriptions withchat.ChannelArchived -> apps.OnChannelArchived.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, every answer checked by the response validator of services/server/test/apitest; world of 07 11.1: Owner, Alex with G-1, Sam, Robin, space Support with operator Lotse and channels Allgemein and Eskalation, app Tracker installed through installApp with scope messages:write only and grant G-6 write on Support/Eskalation, a forum channel in Support; step-up through the M1-35 flow helper):
TestIncomingWebhooksMigration: columns, foreign keys withON DELETE RESTRICT, uniquetoken_hash, partial index onchannel_id, as 03.TestCreateIncomingWebhook: the Owner with step-up gets201;urlis absolute fromserver.public_urland ends with abothy_whi_token; the row holds the HMAC of that token and no part of it; one audit allow entry with{webhook_id, app_id, thread_id}only. Sam, givenreadwithmanageon Support/Eskalation by a grant of the Owner, also gets201. Without step-up401 step_up_required; Alex withtriggerbut withoutmanage403 forbidden; a person without read right404identical to an unknown channel; an app without installation400 validation_failedwithapp_not_installed; an app withoutwritethereapp_cannot_write; a thread of another channel and an unknown threadnot_in_channel; a name with a bidi character400.TestCreateIncomingWebhook_OneTimeSecret: the sameIdempotency-Keyagain gives409 conflictwithreason: one_time_secretandIdempotent-Replay: true; one row only.TestListIncomingWebhooks: active ones in creation order without token or hash, a revoked one missing;403 forbiddenwithoutmanage;404without read right, identical to an unknown channel.TestRevokeIncomingWebhook:204with step-up and audit; the next post404; a second revoke204with an audit entry carryingno_change: true; unknown ID and a webhook of an invisible channel give identical404.TestPostIncomingWebhook_PathAndBearer: the samenativebody through both operations gives201withmessage_idandthread_id; the stored message hasorigin = app, author Tracker,kind = text,silent = false; onemessage.createdwithactor {kind: app}and the echoedclient_msg_id;MessagePostedwithOrigin = app.TestPostIncomingWebhook_NotFoundEquality: unknown webhook ID, well-formed wrong token, malformed token, revoked webhook, uninstalled app, suspended installation, app whose grant was revoked, archived channel, fixed thread moved to another channel (throughmoveThread; M1-62 is in the closure), Bearer with schemeBasic, Bearer missing: all404with identical status, body (ignoringcorrelation_idandinstance) and headers (ignoringCorrelation-Id,RateLimit,RateLimit-Policy), for both operations.TestPostIncomingWebhook_OrderOfChecks(11 8.4): withrl.webhook_inof the webhook exhausted a wrong token gets429 rate_limited; a wrong token with a body abovewebhook_in.max_body_bytesgets404(token before size); a valid token with that body gets413 payload_too_largenamingwebhook_in.max_body_bytes; a valid token with an unknown member gets400 validation_failed(unknown_member); a valid token with a switched-off format getsformat_disabled; a valid token for an app that lostwritegets404; otherwise201.TestPostIncomingWebhook_FailedTokensCountAgainstPublicAuth: wrong tokens from one client IP use uprl.public_auth; then a valid token from that IP gets429 rate_limitedwithout a token check (the verifier fake counts zero calls); another client IP still gets201.TestPostIncomingWebhook_RateLimitPerWebhook: two webhooks; exhaustingrl.webhook_inof one leaves the other at201;RateLimit-Policynamesrl.webhook_in;bothy.ratelimit.rejectedgrows withbothy.ratelimit.class = rl.webhook_in.TestPostIncomingWebhook_MentionsNotResolved(R-EX-09): content with@alexand@lotse: nomentionsrow,mention_idsempty, content stored unchanged.TestPostIncomingWebhook_ContentRules: content of exactlymessage.max_content_bytesbytes201, one byte more413 payload_too_largenamingmessage.max_content_bytes; a mixed bodyformat_mixed;client_msg_idof version 4not_uuid_v7.TestPostIncomingWebhook_TargetRules: fixed thread; bodythread_idof the channel; bodythread_idof another channel lands in the main thread;titlein the forum givesthread.createdthenmessage.createdand the post in a new forum thread;titlein Support/Eskalation gives the message in the main thread and a new thread withparent_message_idof it, the answer naming the new thread; forum without title400 validation_failedwithrequired_for_forum_post.TestPostIncomingWebhook_RevokeRace: a post and a revoke behind a barrier: either201before or404after; no message is written after the revoke committed; no deadlock reaches the client.TestPostIncomingWebhook_ChannelPostCondition: a thread moved concurrently between target choice and write leaves no message outside the webhook's channel.TestUninstallRevokesIncomingWebhooks(11 4.3): afteruninstallAppevery webhook of the app hasrevoked_atand posts give404.TestSuspendedInstallationRefusesPosts(11 4.3): with the installationsuspended(throughsuspendAppif it is enabled on the base, else through the apps test fixture) posts give404; after resuming the same URL gives201; the row was never revoked.TestChannelArchiveRevokesIncomingWebhooks:archiveChannelrevokes the channel's active webhooks in its transaction; a webhook of another channel stays active.TestInternalListener_IncomingWebhook(16 4.2): both post operations answer201onserver.internal_listen_addrin rolesapi,workerandall;createMessagethere gives404; failed tokens sent with changingX-Forwarded-Forvalues all count againstrl.public_authof the peer address.TestPostIncomingWebhook_TokenOnlyAsPattern(06 5 point 1, SEC-LOG-2, SEC-LOG-3, plan row): with a token built at runtime, the captured access log and the exported spans hold/api/v1/hooks/{webhook_id}/{token}and never the token; a404, a400and a413answer carryinstance/api/v1/hooks/<webhook_id>/{token}; the Bearer variant logs noAuthorization; audit details and event payloads hold no token.TestLastUsedAtGranularity: two posts withinidentity.last_active_granularitywritelast_used_atonce; after the window it moves.TestAZ_P1_RouteCount: passes with the five operations.
End to end (services/server/test/integrations, the assembled server on loopback, the standard library HTTP client as sender):
TestAZM53_IncomingWebhookNeverTriggers(AZ-M53, SR4, T-TK-03): Support/Eskalation getsfree_response = truethroughputChannelBehavior; anativepost of Tracker's webhook mentioning@lotsegives a message withorigin = app, notrigger_state, norunsrow, no anchor message and norun.state_changedoragent.invocationevent; as a control Alex writes in the same channel without mention and one run of Lotse isqueued, which proves the trigger path is live.TestCronPath_NativeIdempotentPerRun(11 10.4, exit criterion 9): a test job that creates oneclient_msg_idper run and postsnativewith a title per run: run 1 gives201; repeating run 1 gives200withIdempotent-Replay: true, the samemessage_idandthread_id, and exactly onemessage.created; run 2 gives201with a new message; run 1'sclient_msg_idwith other content gives422 idempotency_key_reuse; run 1'sclient_msg_idthrough a second webhook of Tracker gives422 idempotency_key_reuse.TestAlertmanager_ExamplePayloadGivesExpectedMessage(plan row): the firing fixture posted to the path URL gives201and a storedcontent_mdbyte-identical to its golden.md; the resolved fixture gives a second message; bothorigin = app; posted to the internal listener the same; with a fixed thread both land there.
Contract and gates: make api-lint (the new operation, schema and examples), make api-checks (catalogue sync, idempotency exemption list, problem codes), make codegen-check (generated server code and the closed-schema check of M1-18).
test/canary (M1-69, SEC-T10) is in the closure: add the webhook token in the path and in Authorization, and a webhook message text, to its scenario. bothy.message.created (M1-68) is in the closure: assert one count with bothy.message.origin = app per post.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/apps/... ./internal/messaging/... ./internal/identity/... ./internal/authz/... ./internal/kernel/... ./internal/platform/httpserver/... ./internal/wire/... ./api/... ./test/integrations/...make migrations-lock,make migrations-lintmake codegen-check,make arch,make api-lint,make api-checks,make api-tables,make register-checkmake config-reference,make config-doc-checkmake docsmake check
Acceptance criteria¶
-
incoming_webhooksexists exactly as in 03;listIncomingWebhooks,createIncomingWebhook,revokeIncomingWebhook,postIncomingWebhookandpostIncomingWebhookWithBearerare enabled and registered; AZ-P1 passes. - AZ-M53: a webhook message mentioning the operator in a channel with
free_responsehasorigin = appand creates no run, while a human message in the same channel does. - The Alertmanager example payload gives the expected message byte for byte (golden), resolved notifications too; rendering stays within
message.max_content_bytes. - Cron path of 11 10.4: a test job sends
nativewith oneclient_msg_idper run; a repeat of the same run creates no second message, a new run creates a new one. - The path token appears in the access log, in traces and in
Problem.instanceonly as the pattern of 06 "Errors"; the Bearer token never appears. -
rl.webhook_inlimits per webhook and runs before the token check; failed token checks count againstrl.public_auth. - The order of 11 8.4 holds:
429, then404for the token, then413forwebhook_in.max_body_bytes, then400for the format, then404forauthorize, then201. - Unknown webhook, wrong or malformed token, revoked webhook, suspended or uninstalled app and every
authorizedenial answer the same404. - The token is shown once, only its HMAC is stored, and a replay of the creation gives
409 conflictwithreason: one_time_secret. - The target thread follows 11 8.3 with the rulings of Design note 5; mentions are never resolved.
- Revoke, uninstall and channel archive end posting; no post writes after their commit.
- Both post operations work on the internal listener in every role.
- The spec and API changes of Design note 15 and the rulings of Design note 16 are in the same change.
-
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,messaging,identity,authzandinternal/platform/httpserverupdated (portIncomingWebhooks, the token check stage and its place in the chain, the webhook actor,SkipMentionsandHashContext, the new identity token functions, the requirement ofwebhook_in.post, the subscription tochat.ChannelArchived, the internal listener routes). - Rulings of Design note 16 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- An 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); 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.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.