| D-M3-20-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); the APNs adapter is built in its own start part of the notify module (startAPNs, order key 1365 in the start step chat, right after startNotify) in internal/wire/module_notify.go, because the jobs function of a module has no context to resolve the key with, and buildPush adds those providers to Deps.PushProviders (PL/28); the changelog entry and these rulings are fragments (PL/24). Two test fixes of the old stack are not needed on main, which already carries them. |
The rules of the landing replace the integration branches; main is the reference. |
| D-M3-20-1 |
2026-10-21 |
egress.Options.HTTP2 sets ForceAttemptHTTP2 on the transport, so TLS negotiates h2 by ALPN; plain http stays HTTP/1.1. The adapter builds one client with Purpose push_apns, RedirectNever, HTTP2 and a host policy of exactly api.push.apple.com and api.sandbox.push.apple.com on https port 443. Deps.Endpoints is set only by tests, which still pass every egress check through the Fake's allow_private rule and its root CA. The adapter sets exactly the headers of 13 5.1 and an empty User-Agent, so none is sent; net/http adds accept-encoding: gzip itself, and the header test allows that and content-length only. Place: internal/platform/egress/client.go, internal/notify/adapters/apns/adapter.go. |
APNs accepts only HTTP/2 (13 5.1), and the egress package is the one place that builds transports (19 SEC-NET-1); the egress package exposes no switch for the transport's compression, and adding one is outside the brief. |
| D-M3-20-2 |
2026-10-21 |
APNs is enabled exactly when push.apns.team_id, push.apns.key_id and push.apns.topics are set. push.apns.private_key_ref keeps the empty default of 13 17 instead of the brief's file:<secrets.dir>/apns_key.p8, and the cross-check push_apns_complete requires it when APNs is enabled, besides refusing a partial set and an empty or repeated topic. internal/wire (APNsProviders, called by startAPNs of module_notify.go) builds the adapter in worker and all only, resolving and parsing the key once at start (an error names the key, never a value); bothy config check --secrets prints skipped (read only with APNs enabled) for the key otherwise. Place: internal/wire/push_apns.go, configkeys_push.go, configchecks.go, cmd/bothy/config.go. |
The brief assumed that the schema refuses an empty secret reference default, but the merged schema allows it (optionalRef, as server.tls_cert_ref), so the chapter's value stays and no 13 17 change is needed; requiring the reference in the cross-check makes bothy config check report what the start would refuse. |
| D-M3-20-3 |
2026-10-21 |
ParsePrivateKey accepts exactly one PEM block PRIVATE KEY with a PKCS #8 ECDSA key on P-256 and only white space around it; errors name the reason only. The signer is a jose.SignerFunc over jose.ES256Signer (R and S of 32 bytes each) instead of an own type implementing jose.Signer, because the deep scan of go-arch-lint lets only kernel types cross into kernel/jose. The JWT is jose.SignCompact with jose.Header{Kid}, which writes exactly alg and kid, so kernel/jose does not change; the claims are exactly iss and iat. Every creation resolves push.apns.private_key_ref again; New resolves and parses but signs nothing, so the first send creates the first JWT. Place: internal/notify/adapters/apns/key.go, token.go. |
13 5.1 and ADR 0067; reading the key at each creation lets a rotated key take effect after secrets.cache_ttl and the next interval without a restart (16 5.2). |
| D-M3-20-4 |
2026-10-21 |
One JWT cache per environment, guarded by one mutex under which the JWT is created; a JWT is reused while now < created + push.apns.jwt_refresh_interval. 403 ExpiredProviderToken for the current token creates one replacement (a new interval) and repeats the request once; a parallel send that got the answer for the same token reuses that replacement; a send whose token was already replaced by a normal renewal retries with the current one. A forced replacement within the last interval, or a second ExpiredProviderToken on the retry, is a configuration error. Place: internal/notify/adapters/apns/token.go; 13 5.1. |
At most one JWT per interval per environment (13 14 row "JWT cache") with the one exception 13 7.1 names; the environments are separate because one key set serves both (D-M3-20-8). |
| D-M3-20-5 |
2026-10-21 |
A configuration error (a 403 reason other than ExpiredProviderToken, a key that cannot be resolved or parsed) drops the JWT, sets pausedUntil = now + push.apns.jwt_refresh_interval and writes one Error record. While paused, Send returns config_error without a request or a record. The first send at or after pausedUntil is the only probe: it creates a new JWT, and parallel sends during the probe stay paused; an answer other than a configuration error ends the pause with one Info record, a configuration error starts the next pause, and a probe without an answer (a transport error) keeps the pause so the next send probes again with the same JWT while it is valid. The pause lives in the process. The resolve error of TestConfigErrorPausesEnvironment comes from a failing resolver, because the secret provider of M1-09 keeps serving a value it has read when the file disappears later. Place: internal/notify/adapters/apns/token.go; 13 5.1. |
13 7.1 says "APNs sending paused" without an end; one probe per interval heals a rotated key or a fixed clock without a manual step and keeps the token updates within Apple's limit. |
| D-M3-20-6 |
2026-10-21 |
The payload is built from structs in the key order of 13 6.1 (aps: alert with title-loc-key and loc-key, mutable-content, thread-id, category, sound, badge without omitempty, interruption-level; b: v, nid, k, i, t, m), quiet wins over time-sensitive, Count and TargetID are not carried. Headers: authorization, apns-push-type: alert, apns-id = nid, apns-topic = app_id, apns-priority 10 or 5 (quiet, sync), apns-expiration = now plus push.ttl, apns-collapse-id = thread ID when there is one and not for the sync, content-type. Pre-checks without a request: another provider or environment, an unknown kind, a zero nid or instance ID, HasBadge false or more than Apple's 4096 bytes give program_error; an empty or non-hex token disable token_invalid; an app_id outside push.apns.topics disable topic_mismatch. The sync of 13 8 is exactly {"aps":{"badge":<n>}}; Cleared is not sent. Place: internal/notify/adapters/apns/payload.go, adapter.go; 13 6.1. |
ADR 0031 (no content at Apple), 13 5.1 and 8; a non-hex token would land in the URL path and can never be valid. |
| D-M3-20-7 |
2026-10-21 |
The answer table of 13 7.1 with its unlisted rows: any 410 gives disable token_invalid with InvalidSince = timestamp; 400 BadDeviceToken disable token_invalid; 400 DeviceTokenNotForTopic disable topic_mismatch; any other 403 gives config_error; 429 TooManyProviderTokenUpdates and every other 4xx (413, other 400, 404, 405) program_error; any other 429 gives throttled without RetryAfter; any 5xx retry with RetryAfter = push.apns.retry_5xx_delay; any other status program_error; no answer a Go error. A missing or broken body leaves the status alone to decide. Place: internal/notify/adapters/apns/classify.go; 13 7.1. |
Apple documents the status as the class and the reason as detail [Q15]. |
| D-M3-20-8 |
2026-10-21 |
"Alarm" is one Error record (apns configuration error, apns program error) with exactly bothy.push.provider and error.type (the reason of the table or other, for a key error the secrets code or invalid_key) plus the outcome rejected_other of M3-19; no new metric, attribute or alert rule. A pause logs at its start and at its end only. 400 BadDeviceToken writes one Warn record apns bad device token in production or in sandbox and no alarm. No record or error text carries a token, the JWT, the key, the topic or an ID. One key set serves both environments, with JWT cache and pause per environment. Place: internal/notify/adapters/apns/token.go, adapter.go; 13 7.1 and 11. |
13 7.3, SEC-LOG-4; one answer cannot tell a wrong environment from a removed app, and 13 17 and configuration.md have one key set while Apple keys work for both environments unless restricted [Q3]. |