Skip to content

M3-21 FCM adapter

No Date Ruling Reason
D-M3-21-0 2026-10-23 Built on the old integration line and ported to main after M3-19 landed: only the ticket's own commits were brought over. The additions to the German chapter files of the old base go to their English files on main (13-push-notifications.md, 02-architecture.md), and TestPayloadMatchesChapterExample reads only 13-push-notifications.md; the FCM adapter is built in the start part startFCM of internal/wire/module_notify.go (order 1366 in the chat step after the APNs part of M3-20, roles worker and all), because the jobs hook of a module wiring has no context for resolving the service account, and buildPush adds it to Deps.PushProviders (PL/28); the changelog entry and these rulings are fragments (PL/24). The integration rulings of the old base (the stacked merge of M3-19 and the test fixes main already carries) are dropped. The rules of the landing replace the integration branches; main is the reference.
D-M3-21-1 2026-10-21 The adapter fcm builds one egress client (purpose push_fcm, RedirectNever) over HTTP/1.1 with keep-alive, never HTTP/2; its own host policy allows exactly scheme https, port 443 and the hosts fcm.googleapis.com and oauth2.googleapis.com, so no egress.allowed_hosts entry is needed, and send and token requests share the client. The token URL is fixed at https://oauth2.googleapis.com/token; a service account whose token_uri is present and differs is refused at start. Deps.Endpoints replaces both base URLs for tests only; internal/wire never sets it, and a test that sets it still runs every egress check. Place: internal/notify/adapters/fcm/adapter.go, 13 5.2 and 11. 19 SEC-NET-1 and SEC-NET-2: every connection through the egress client with the targets the consumer names. The FCM HTTP v1 API accepts HTTP/1.1 [13 Q8] and 13 demands HTTP/2 only for APNs; a URL from a secret file would make the secret a second egress configuration and an SSRF path.
D-M3-21-2 2026-10-21 FCM is enabled exactly when push.fcm.project_id is set; the cross-check push_fcm_project_id refuses a non-empty value that is not only lower-case letters, digits and hyphens starting with a letter, naming the key. The default of push.fcm.service_account_ref is file:<secrets.dir>/fcm_service_account.json (13 17 changed). push.ttl is bounded to Min 1 second and Max 28 days in every role. Enabled, the roles worker and all build the adapter, which resolves and parses the service account at start (an error stops the start naming push.fcm.service_account_ref) and sends no request; the role api and a disabled instance build none and read no secret, and jobs for fcm registrations end with no_provider. bothy config check --secrets resolves the key only when FCM is enabled. Place: internal/wire/configkeys_push.go, internal/wire/configchecks.go, internal/wire/push_fcm.go, cmd/bothy, 02 13 item 4, 13 17. The project ID becomes a path segment of the send URL; the merged schema allows an empty default only for string and list keys, and docs/operations/configuration.md section 5 already names fcm_service_account.json; 28 days is the FCM limit of android.ttl [13 Q11] and 1 second keeps android.ttl at least one whole second; fail fast without needing the network; an instance without FCM has no service account file.
D-M3-21-3 2026-10-21 The service account is a JSON object with type = service_account, non-empty client_email and private_key_id, a private_key of exactly one PKCS#8 PEM block holding an RSA key and an optional token_uri equal to the token URL; other fields are ignored, and no error text holds a byte of a value. The assertion is signed with jose.RS256Signer of kernel/jose (no own signer): header exactly alg = RS256, typ = JWT, kid = private_key_id; claims exactly iss = client_email, scope = https://www.googleapis.com/auth/firebase.messaging, aud = the token URL, iat and exp = iat plus one hour; it is posted with the JWT bearer grant (RFC 7523 2.1). Every token request resolves and parses the service account again. Place: internal/notify/adapters/fcm/account.go, internal/notify/adapters/fcm/token.go, 13 5.2. 13 5.2 and 11 [13 Q8]; one hour is the longest assertion lifetime Google accepts [13 Q17]; ADR 0067 item 2 lists RS256 for signing assertions and the merged kernel/jose already exports the signer; reading the key at each token request makes a rotation work without a restart (16 5.2).
D-M3-21-4 2026-10-21 The access token is reused while now is before its time of issue plus half its expires_in; one mutex guards the cache and the token request runs under it, so at most one token request is in flight however many sends run in parallel, and a failed request stores nothing. A 401 without THIRD_PARTY_AUTH_ERROR (UNAUTHENTICATED) for the current token drops it, gets a new one (parallel sends reuse the replacement) and sends the same message once more; a second 401 for the replacement is a configuration error. Place: internal/notify/adapters/fcm/token.go, internal/notify/adapters/fcm/adapter.go, 13 5.2. Half the lifetime leaves a wide margin for clock skew and request time without a new configuration key, and the ratio derives from the provider's own answer; a revoked or expired token heals with one request instead of losing the hint.
D-M3-21-5 2026-10-21 A configuration error of the credentials (401 twice, 403 PERMISSION_DENIED, 404 NOT_FOUND, a token request refused with a 4xx, a failed resolve or parse of the service account at that time) drops the token, writes one Error record and pauses the adapter for push.retry_cap: meanwhile Send returns config_error without a request and without a log record. The first send at or after the end probes once with a new token, reading the key again; any other result ends the pause with one Info record, a configuration error starts the next pause. SENDER_ID_MISMATCH and THIRD_PARTY_AUTH_ERROR never pause; a token endpoint answering 429, 5xx or not at all gives a transient Go error and no pause. The pause lives in the process. Place: internal/notify/adapters/fcm/token.go, 13 5.2. 13 7.2 asks for an alert and no repetition, but without a pause every hint would ask the token endpoint again with a revoked key; push.retry_cap is the longest wait the push path already names, so a rotated key or a fixed project heals within it without a manual step, and dropped hints are carried by the unread counts (13 7.3); answers about one registration or a transient endpoint do not show the credentials wrong.
D-M3-21-6 2026-10-21 The message is built from structs in the key order of 13 6.2: message with token, android (priority high, normal for quiet hints and the sync form; ttl = push.ttl in whole seconds plus s) and data with only v, nid, k, i, t, m, badge, quiet, every value a string; t and m are absent without thread or message, badge is always present, Count and TargetID are not carried, and no notification, collapse_key, apns, webpush or fcm_options key exists. Pre-checks without a request: wrong provider, unknown kind, zero nid or instance ID, or no badge give program_error; an empty token or one with a byte outside printable ASCII or a space gives disable with token_invalid; environment and app_id are not read. data above the FCM limit of 4096 bytes gives program_error and is never truncated. The sync form has v, nid, k = "sync", i, badge and cleared (comma-separated, in the order given), without t, m and quiet; when cleared would exceed the limit it is left out entirely. Place: internal/notify/adapters/fcm/payload.go, 13 6.2. ADR 0031 and 13 1 point 1: no content at Google, an allow list of keys and value types; R-PN-08 and D-PN-06: no collapse_key; the limit is Google's [13 Q9]; a partial cleared list would leave the app unable to tell which notifications remain, so the list is complete or absent, the same limit 13 8 states for iOS.
D-M3-21-7 2026-10-21 The answer table of 13 7.2 is a pure function of status, body, header and time; the FCM error code is errorCode of the FcmError detail and the token cause of INVALID_ARGUMENT a fieldViolations entry on message.token in the BadRequest detail or the FcmError code INVALID_ARGUMENT without a violation on another field (D-M3-21-17); error.message is never read into a log or an error text. Unlisted answers follow the nearest listed answer of the same status: any other 400, 4xx or status outside 2xx, 4xx, 5xx is program_error; 401 otherwise renews (D-M3-21-4); 403 and 404 otherwise are config_error with pause; any 429 is throttled; any 5xx is retry. Retry-After is read as delta seconds or as an HTTP date against the clock, a negative, unreadable or past value counts as absent; 429 waits at least one minute; without Retry-After the job's domain.RetryDelay applies with its jitter in [0.5, 1], so the first retry comes between half of push.retry_base and push.retry_base, every retry at most push.retry_cap later, and a Retry-After beyond push.max_age ends the hint as expired. Place: internal/notify/adapters/fcm/classify.go, 13 7.2. Google documents the HTTP status as the class and the error code as its detail, and the one minute minimum after QUOTA_EXCEEDED [13 Q16]; error.message may echo request values; "backoff between push.retry_base and push.retry_cap" in 13 7.2 is read with the jitter of 13 7.3.
D-M3-21-8 2026-10-21 "Alert" and "configuration alert" of 13 7.2 are one Error record (fcm configuration error, fcm program error, fcm sender mismatch) with exactly bothy.push.provider = fcm and error.type (the FCM error code, else error.status when it is UNAUTHENTICATED, PERMISSION_DENIED or NOT_FOUND, else the OAuth error of a refused token request when it is invalid_grant, invalid_client, unauthorized_client, invalid_scope or invalid_request, else other), plus the outcome rejected_other that M3-19 records for config_error and program_error, which BothyPushAcceptanceBudgetBurn watches through the SLO bothy-push/provider-acceptance. SENDER_ID_MISMATCH is disable with outcome rejected_token, which burns no budget; its Error record, one per answer, is the configuration alert. A pause logs once at its start and once at its end. No log, span, metric or error text carries the device token, the assertion, the access token, the authorization header, key material, the client e-mail, the project ID, an ID of the hint, Google's error.message, a name or a text. No new metric, attribute or alert rule. Place: internal/notify/adapters/fcm/adapter.go, 13 7.2. 13 7.3 and 10, SEC-LOG-4, SEC-SEC-3; the existing outcome and SLO already alert on credential and program errors; a token of another project's app is not a broken server, so it must not burn the acceptance budget.
D-M3-21-11 2026-10-21 A pause caused by the service account at a token request writes fcm configuration error: push.fcm.service_account_ref cannot be used with error.type = secrets.ErrorCode of the resolve (for example not_found) or invalid_service_account for a value that does not parse; checks before a request (program_error, disable) write no record, only provider answers do. Place: fcm/token.go, fcm/adapter.go. Design note 7 a names no error.type for these causes; the codes are fixed values and never a reference or a value.
D-M3-21-12 2026-10-21 The secret provider keeps serving its cached value when a file disappears (M1-09), so the test "service account missing before a token request" uses a resolver that fails; the test of a replacement token revoked inside the same send uses the unexported hook tokenState.onToken. Place: fcm/helpers_test.go, fcm/token_test.go. Neither case can be produced through the real provider and Fake-FCM alone.
D-M3-21-13 2026-10-21 data passes the size check up to and including 4096 bytes; a Retry-After in delta seconds beyond the range of a Go duration counts as the longest duration, so the job ends the hint as expired. Place: fcm/payload.go, fcm/classify.go. Google states the limit as 4096 bytes; a huge but valid header is no "unparsable" value.
D-M3-21-15 2026-10-21 The integration world of test/integration/notify gets the option withFCM (a real adapter against one Fake-FCM in place of the fakepush provider fcm). Place: test/integration/notify/world_test.go. The job path is proven against the adapter and Fake-FCM, not only against the fakepush provider.
D-M3-21-16 2026-10-23 The request deadline (egress.timeout) of every test world against Fake-FCM comes from fcmtest.Server.EgressConfig(testkit.Patience), the one place that builds the egress configuration for the Fake; TestWorldDeadlineIsPatience keeps the unit world on it. Place: fcm/fcmtest/server.go, fcm/helpers_test.go, test/integration/notify/fcm_test.go. A fixed deadline of 10 seconds made TestTokenCache_OnePerHalfLifetime (200 parallel TLS sends) able to fail on a loaded machine (red team); the only bound of a wait for something that must happen is testkit.Patience.
D-M3-21-17 2026-10-23 A 400 whose FcmError detail carries INVALID_ARGUMENT and whose details name no field other than message.token is an invalid token: disable with token_invalid; a BadRequest violation on another field keeps program_error. Fake-FCM gains Response.OtherField for that shape. Place: fcm/classify.go (parsed.tokenCause), fcm/fcmtest/server.go, 13 7.2. Google documents an invalid registration token as the FcmError detail alone, without a BadRequest detail, and a bad field value as a BadRequest violation on that field [13 Q16]; reading only message.token violations would keep a dead token active and turn every hint to it into a program error (review finding).