| D-M2-27-1 |
2026-10-20 |
The Bearer variant of 11 8.1 and 10.3 is its own operation postIncomingWebhookWithBearer on POST /api/v1/hooks/{webhook_id}, besides postIncomingWebhook on POST /api/v1/hooks/{webhook_id}/{token}; both have the action webhook_in.post, the class rl.webhook_in, the exemption client_id, the request body IncomingWebhookPost and the same responses. |
One operation cannot have two paths, and the plan row asks for both. |
| D-M2-27-2 |
2026-10-20 |
The token check is the chain stage httpserver.StageWebhookToken, directly after StageRateLimit and before StageMaintenance and the body stages, so it runs after rl.webhook_in and before any body is read or validated; it acts only on operations with action webhook_in.post, and every failure (malformed token, unknown ID, wrong hash, revoked webhook, installation not active) gives the same 404 not_found; 02 9.1 gets the step. Place: apps/adapters/httpapi/incoming_stage.go (TokenStage), wire/incoming.go. |
11 8.4 orders the checks rate limit, then token, then size and content; a chain stage keeps that order for both listeners. |
| D-M2-27-3 |
2026-10-20 |
The actor of a verified post (apps/app.WebhookActor) is the app principal (ID = incoming_webhooks.app_id, Kind app) with the InstallationID of its active installation, GrantedScopes = the installation's granted scopes intersected with messages:write, and no TokenID, SessionID or DeviceID. |
A stolen URL may only post into its channel (T-TK-03); message.create and thread.create need exactly messages:write (05 2.6). |
| D-M2-27-4 |
2026-10-20 |
The requirement of webhook_in.post is All(AppKind, AtLeast(LevelWrite)) on the channel of the webhook, without scope, and no longer deferred; an anonymous principal gets unauthenticated. The use case authorizes webhook_in.post on every post, replays included, and the messaging use case then authorizes message.create (and thread.create) for the same actor; every outcome other than allow (not_found, forbidden with any reason, unauthenticated, step_up_required) becomes the 404 not_found of an unknown webhook, internal errors stay 500 internal_error. Place: authz/domain/requirements_table.go, apps/app/incoming.go (asNotFound). |
07 4.2 allows anonymous only for the right public, and this right is "public with token; the app needs write"; 11 8.4 sends the branch "authorize app: message.create on the channel" with "no" to 404, so a denial reveals nothing. |
| D-M2-27-5 |
2026-10-20 |
The format is decided per request from the body (incoming.Classify), not stored per webhook; the request body is one closed 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 no required list, plus the schema AlertmanagerAlert. A body with an Alertmanager member is alertmanager and must hold no native member, else 400 validation_failed with the pointer of the first native member and format_mixed; every other body is native; a format not in webhook_in.formats_enabled gives 400 validation_failed with an empty pointer and format_disabled. |
03 incoming_webhooks has no format column (core contract), openapi has no format member, and rule/oneof-needs-discriminator forbids a oneOf without discriminator while the Alertmanager payload has none. |
| D-M2-27-6 |
2026-10-20 |
A fixed thread is used only while it belongs to the webhook's channel (then thread_id and title of the body are ignored); when it was moved to another channel, the post gives 404 not_found. Place: apps/domain/incoming/target.go (ChooseTarget). |
A webhook writes only into its own channel (11 8.1 "for a channel", T-TK-03). |
| D-M2-27-7 |
2026-10-20 |
A native post with title into a text channel writes the message into the main thread (chat.TriggerChannel.MainThreadID) and starts a new thread from it (parent_message_id = the message), like auto_thread of 09 3.7; the answer names the new thread in thread_id, and a replay answers the thread of the first answer (chat.ThreadsByParent) and creates none. In a forum a title creates a forum post with the message as first message. |
In a text channel a thread grows from a message (01 4, M1-55 D1 requires the parent), and 11 8.2 says one message per call. |
| D-M2-27-8 |
2026-10-20 |
The request hash of a native post uses messaging.CreateMessageCmd.HashContext = "webhook:" + webhook_id + "\|thread:" + <thread_id of the body or empty> + "\|title:" + <title or empty> (incoming.HashContext); the message is written with SkipMentions. A replay (same author, client_msg_id and hash) answers 200 with Idempotent-Replay: true and publishes nothing. |
The hash covers the request as sent (04 5.1 point 3), and a client_msg_id repeated through another webhook of the same app gives 422 idempotency_key_reuse instead of a replay of another webhook's message. |
| D-M2-27-9 |
2026-10-20 |
After the commit TouchIncomingLastUsed sets last_used_at only when it is empty or older than identity.last_active_granularity, outside the unit of work; a failure is logged without the token and never fails the request. |
The same rule as api_tokens.last_used_at (03); concurrent posts never upgrade their share lock. |
| D-M2-27-10 |
2026-10-20 |
incoming.RenderAlertmanager writes a fixed layout, held by golden tests (apps/domain/incoming/testdata): a header **FIRING** or **RESOLVED** with the title (commonLabels.alertname, else groupLabels.alertname, else Alerts) and the alert count; one list item per alert with status, alert name, severity when labelled and summary (annotation summary, else description, else the other labels as sorted name=value, else no summary), with Runbook: and Source: lines; a last item - and <k> more alerts for truncatedAlerts and dropped items; the footer Alertmanager: with externalURL. Payload values have control characters replaced by a space and every ASCII punctuation character escaped; a URL is written only as an autolink of an absolute http or https URL with a host made only of printable ASCII without < and > (a character such as U+FEFF would end the autolink in a client and let the rest render as Markdown), else its line is left out; above message.max_content_bytes items are dropped from the end, then the footer, then the text is cut at a UTF-8 boundary with .... |
11 8.2 lists the contents, not the layout, and docs/observability/alerting.md 2 adds severity and runbook link; the escaping keeps a label from adding formatting, links or images (SEC-OUT-1), and the server never fetches these URLs (SEC-OUT-2). |
| D-M2-27-11 |
2026-10-20 |
createIncomingWebhook answers 400 validation_failed with /app_id and app_not_installed when the app has no active installation, with /app_id and app_cannot_write when the webhook actor of the app may not message.create in the channel (checked in filter mode, no audit), and with /thread_id and not_in_channel when thread_id is unknown or a thread of another channel. |
11 8.1: the webhook always writes as an app with write on the channel, and a manager sees the same through "Who has access?"; an unknown thread and a foreign one give the same answer. |
| D-M2-27-12 |
2026-10-20 |
listIncomingWebhooks lists only active webhooks, ordered by created_at, then id, without token or hash. |
The partial index of 03 serves exactly this list; the history of revoked webhooks is in the audit log. |
| D-M2-27-13 |
2026-10-20 |
While the app's installation is not active (suspended), its webhooks stay but every post gives 404 not_found; after app.resume the same URL works again. |
Suspend is an emergency stop for everything the app writes (11 4.3, R-EX-05, 07 4.1 app_not_installed). |
| D-M2-27-14 |
2026-10-20 |
apps/app.ArchiveSubscriber.OnChannelArchived sets revoked_at on the active webhooks of an archived channel in the archive transaction, without an audit entry of its own; uninstalling an app revokes its active webhooks in the unit of the uninstall (revokeIncomingForApp). |
Archiving is irreversible (05 has no unarchive), so such a webhook could never post again; the archive and the uninstall are the audited actions. |
| D-M2-27-15 |
2026-10-20 |
Both post operations are served on the internal listener in every role (api, worker, all) with identical behaviour; health stays as it is. Place: wire/incoming.go (mountInternalHooks). |
Senders inside the stack, such as Alertmanager, reach the routes without the public edge (16 4.2, 02 3.2, docs/observability/alerting.md 2 point 3). |
| D-M2-27-16 |
2026-10-20 |
IncomingWebhookCreated.url is absolute: server.public_url joined with /api/v1/hooks/{webhook_id}/{token}; Bearer senders drop the last segment and send the token in the Authorization header. |
The schema says "Full URL", and the sender needs nothing else to post. |
| D-M2-27-17 |
2026-10-20 |
postIncomingWebhookWithBearer has no operation-level security and so carries the root security; of the root schemes only bearerAuth applies to it, with the whi token, which the token check verifies, not the authenticator (httpserver.Operation.AnonymousCredentials). |
The route generator records only public or the root security (M1-17, M1-19). |
| D-M2-27-18 |
2026-10-20 |
The internal listener serves the two post operations through a chain of its own with empty trusted proxies (the client IP is the peer address, R-SE-09) and its own rate limiter, so rl.webhook_in and rl.public_auth count per listener; nothing else of the API is mounted there. Place: wire/incoming.go. |
Forwarded headers are never read on the internal listener (M1-21 design note 1), and the public chain must not serve that mux. |
| D-M2-27-19 |
2026-10-20 |
Two additive chat changes: the port chat.ThreadsByParent (ThreadByParentMessage) for the replay of a text-channel post with title, and chat.TriggerChannel.MainThreadID as the target of a post. |
The replay must answer the thread of the first answer; the webhook actor holds only messages:write, so channel.read and getChannel are closed to it. |
| D-M2-27-20 |
2026-10-20 |
The token check is the apps middleware TokenStage at httpserver.StageWebhookToken. For operations with AnonymousCredentials, StageAuthenticate runs neither the credentials precheck nor the authenticator, and the token stage takes the rl.public_auth token through ratelimit.Limiter.FailedCredentialGuard (an empty bucket gives 429 rate_limited before the check). A declared Content-Length above an operation's Operation.BodyLimit (webhook_in.max_body_bytes, named by BodyLimitKey) is refused at StageBody instead of StageRoute. |
A failed token is a failed credential (02 9.1, 05 3); the token check answers before the size (11 8.4: token before size). |
| D-M2-27-21 |
2026-10-20 |
The archive subscriber of apps (apps/app.ArchiveSubscriber, registered after the runs acceptor) uses ChannelArchived.ArchivedAt as revoked_at. |
The revocation carries the time of the archive it follows. |
| D-M2-27-22 |
2026-10-22 |
On main the table is 00055_apps_incoming_webhooks.sql, the next free number after 00054_instance_restore_identity.sql of M7-04; content and checksum are those of the build. |
goose refuses two migrations with the same version, and migration numbers follow the order of landing on main. |
| D-M2-27-23 |
2026-10-20 |
ParseAlertmanager checks the schema rules again (version, the status enums, at least one alert, truncatedAlerts not negative) with the codes of the schema layer (invalid_enum, too_few_items, too_small, required); an empty alertname, severity, summary or description counts as missing, and the last item - and <k> more alerts is always plural. |
The domain function holds its rules without relying on the HTTP layer, with the same codes a client sees from StageValidate; an empty value would render an empty field. |
| D-M2-27-24 |
2026-10-20 |
The integration and end-to-end tests of the brief run in services/server/test/integrations on the shared harness (the assembled server of internal/wire, role all, PostgreSQL 18 through pgtest, the matrix world built through the public API, the standard library HTTP client as sender), not in per-package worlds of internal/apps; the authz check of the webhook actor (TestWebhookActor_ScopesLimitedToMessagesWrite) runs in internal/apps/app on the apps world. TestPostIncomingWebhook_ChannelPostCondition is not built: the post-condition (the written message lies in the webhook's channel, else rollback and 404) has no seam to move a thread between the target choice and the write without a test hook in production code. |
One world through the real chain proves the order of checks, the uniform 404 and the listener behaviour where they happen; the post-condition stays in the code and is held by review. |
| D-M2-27-25 |
2026-10-22 |
The ticket was built on an integration branch whose spec chapters had German file names; the port to main writes the same changes into the English chapters (02-architecture.md, 06-api.md, 11-extensions.md, 16-operations.md). The default of webhook_in.formats_enabled is written ["native", "alertmanager"] in the chapter table, the form the configuration reference compares with the schema. |
main holds the English spec, and the rulings name its chapters. |
| D-M2-27-26 |
2026-10-22 |
A matched route writes a {token} segment of its template into Problem.instance as %7Btoken%7D, the percent-encoded template segment, replacing "kept as {token}" of M1-18/7. In the contract test G7 the two post operations of the incoming webhooks answer the uniform 404 not_found in SEC-T06, because the pass holds no webhook token and the token check runs before the body is read; their body rules are tested in test/integrations. The 404 equality check compares answers for well-formed IDs only. Place: platform/httpserver/problem.go, tools/contract/live_contract.py. |
The schema Problem declares instance as uri-reference, which has no braces, so G7 rejected the literal; 11 8.4 orders the token check before size and content. |