Skip to content

M3-22 Push registration and preview endpoint

No Date Ruling Reason
D-M3-22-0 2026-10-23 Names of the code that differ from the brief; the code wins. domain.ValidateRegistration(in, pol) returns (RegistrationInput, error): the registration as stored (environment production when absent, an apns token in lower case) and an *errs.Error (validation_failed with the field list, or feature_disabled), not []FieldError. RegistrationPolicy lives in notify/domain and internal/wire builds it with wire.RegistrationPolicy. The use cases Active, Register, Unregister and Preview are methods of one type app.Registrations (NewRegistrations). The SQL queries carry the brief's names; the methods of the outgoing port app.RegistrationWriter over them are LockByProviderToken (LockRegistrationByProviderToken), Delete (DeleteRegistration), DisableOthersOfApp (DisableOtherRegistrationsOfApp), Insert (InsertRegistration), ByPreviewHash (GetRegistrationByPreviewHash), OwnerOf (GetRegistrationForOwner), DisableOfSessions (DisableRegistrationsOfSessions), DisableOfDevice (DisableRegistrationsOfDevice) and OfDevices (ListRegistrationsOfDevices); app.PreviewRegistration names the session SessionID, not AuthSessionID. RegisteredPush.PreviewToken is notify.Secret, a redacting type of the notify port, not identity's Token; identity.PushPreviewToken.Secret is identity's Token. The subscribers are Subscribers.OnSessionEnded and Subscribers.OnDeviceRevoked of the existing app.Subscribers, which gets its store through WithRegistrations (app.DeactivationStore). actor.Actor.KeyThumbprint already existed and is reused; TestSessionActor_KeyThumbprint is part of TestSessionActor_ActiveRevokedExpiredForeignUnknown. ErrTokenMalformed already existed and is reused. The reads of the builder are the narrow ports app.PreviewMessages (messaging.Messages.ForPreview, new), app.PreviewNameSource (chat.Structure.PreviewNames, new, because Threads.Info returns no names), app.PrincipalSource (identity.Directory), app.SettingsSource (the settings of sync) and app.AttachmentSource (files.Attachments.ForMessages); the image ticket goes through app.TicketIssuer (files.Files.IssueTicket, actor from the context), the badge through app.BadgeSource (BadgeCounter.For). The metric is metrics.PushFetch with the adapter otelmetrics.Fetch (NewFetch) behind app.FetchMetrics; the metric Spec has no role field. internal/wire binds no empty identity.PushTargets source: an unbound device handler leaves push_registrations out until startPushRegistrations binds notify. No Markdown-to-plain-text function existed, so domain.Excerpt is new. The changelog entry and these rulings are fragments (PL/24) instead of lines in services/server/CHANGELOG.md and a section of docs/DECISIONS.md. Place: internal/notify, internal/wire/notify.go, internal/wire/module_notify.go. The canary scenario of SEC-T10 registers the push target with apns against Fake-APNs and not with fcm as design note 15 says: the canary server runs in role all, where an enabled fcm builds the real FCM adapter that would call the FCM service, and the harness has a fake only for APNs; an fcm attempt with a canary token in the scenario still checks that a disabled provider answers 403 feature_disabled with the reason push_provider_disabled without echoing the token. The integration tests of test/integration/pushreg run in role api with both providers enabled. The Device of a token response (login, refresh, pairing, enrollment) carries no push_registrations member: only the device operations fill it. The brief was written in parallel with M3-05 and M3-17 to M3-23 and before the code; merged and written names win (D-W-100). identity's Token cannot cross into the port of notify (02 4.4), so the port carries its own redacting type.
D-M3-22-1 2026-10-23 notify may use files (attachments and fetch tickets for the preview): the edge notify -> files stands in architecture/modules.yaml, 02 4.4, the C4 component diagram and the dependency line of the notify README, and points down the tiers of 02 4.4. identity never imports notify: it declares the source port PushTargets (ForDevices(ctx, deviceIDs) with PushTargetView: id, device, provider, environment, app id, created, disabled and its reason, never a token or a hash), and notify implements it with one ListRegistrationsOfDevices query per answer (ascending id, disabled rows included). The Device mapper fills push_registrations in getDevice, listMyDevices, listUserDevices, approveDevice, approvePairing and revokeDevice, leaves the member out for a device without registrations, and calls no authorize, because identity reads only devices it authorized; the device inside a token answer carries no push_registrations. Place: architecture/modules.yaml, 02 4.3 and 4.4, docs/architecture/c4-3-server-components.mmd, internal/identity/pushtargets.go, internal/identity/adapters/httpapi/pushtargets.go, internal/notify/app/pushtargets.go. The full preview needs the attachments of M3-07 and the fetch tickets of M3-05; the pattern of identity.AvatarFiles keeps the dependency direction of 02 4.4.
D-M3-22-2 2026-10-23 The preview token has the form bothy_pvw_<random>_<checksum> of ADR 0061 with the new kind pvw (256 bits from the random source of identity, crypto/rand in production, as 43 Base62 characters; CRC-32 IEEE as 6). Tokens.NewPushPreviewToken returns it as identity's redacting Token with Hash = HMAC-SHA-256 with the pepper of auth.token_pepper_ref over the whole token, the derivation of api_tokens.token_hash; push_registrations.preview_token_hash holds only that HMAC. Tokens.HashPushPreviewToken checks form, kind pvw and checksum and returns ErrTokenMalformed before any HMAC and without a database call. The authenticator refuses a pvw token under Bearer and DPoP on every operation with 401 unauthenticated without a database call, and createToken never derives the kind. The token has no expiry and no refresh of its own: it is valid only through getPushPreview and only while its registration row is active, and it ends with its registration, its session, its device or its person. The gitleaks rule bothy-api-token covers the kind. Place: internal/identity/domain/apitoken, internal/identity/app/apitokens.go, .gitleaks.toml, ADR 0061 point 1, 08 7.1, 13 6.4. The gitleaks rule finds a leaked preview token (SEC-SEC-4) and the checksum refuses junk before the database (ADR 0061 point 2); R-PN-07: an own DPoP-bound token without refresh, so the extension and the app never revoke each other's refresh family.
D-M3-22-3 2026-10-23 After authorize, registerPush checks in this order: unifiedpush or wns give 400 validation_failed with errors[].code not_enabled at /provider; apns or fcm not enabled on this instance (apns.Config.Enabled, fcm.Config.Enabled) give 403 feature_disabled with reason push_provider_disabled; then the field rules, collected into one 400 validation_failed: the platform of the device must be ios or macos for apns and android for fcm (invalid at /provider); an absent environment is production, sandbox with fcm or an unknown environment is invalid at /environment; a token above push.registration_field_max_bytes bytes is too_long at /token, otherwise an apns token that is not a non-empty even number of hexadecimal digits or an fcm token that is not non-empty printable ASCII without white space is pattern_mismatch at /token (one token fault at most); an app_id above the limit is too_long at /app_id, otherwise an apns app_id outside push.apns.topics is invalid at /app_id. An apns token is stored in lower case. A provider outside the four is invalid at /provider for a caller outside the HTTP chain. New configuration key push.registration_field_max_bytes (integer, default 1024, between 64 and 2048). internal/wire builds the policy in every role without building an adapter or reading a secret. Place: internal/notify/domain/registration.go, internal/wire/notify.go, internal/wire/configkeys_push.go, 13 13 and 17. 13 5.4: no adapter for these providers in this server version; 06 5 point 5: a switched-off function answers authenticated principals with feature_disabled so the app can show why; only APNs has two environments (13 5.1); an apns token in the form M3-20 sends in the path; an app_id outside the topics would be disabled as topic_mismatch at the first send; the schema has no length for both fields, and an oversized token would fail the B-tree entry of the unique key (provider, token) with an internal error.
D-M3-22-4 2026-10-23 The takeover of a known (provider, token) locks its row (FOR UPDATE), active or disabled and of any person, and deletes it; pending notify.push_send jobs of the deleted row end with no_registration. Then the other active rows of the caller's device with the same provider and app_id are disabled with disabled_reason = 'token_invalid', locked in ascending id, and the new row is inserted with the caller's person, device and session, the HMAC of a new preview token and failure_count 0. unregisterPush locks the row, answers 404 not_found for an unknown one without a decision, authorizes push.unregister (another person's row is not_found too, the same answer) and deletes it; afterwards the token is free and its preview token worthless. Place: internal/notify/app/registrations.go, internal/notify/adapters/postgres/queries/registrations_write.sql, 13 13. 13 13 says "the old row is disabled", but the unique key (provider, token) of 03 covers disabled rows too, so the old and the new row cannot both exist; deleting has the effect of disabling for the former owner (no push, its preview token hash is gone) and keeps no device token in two places. A provider gives one app installation one current token and the app reports the new one, so without the disable the device would get every hint twice until the old token fails.
D-M3-22-5 2026-10-23 The handler marks the answer of registerPush as a one-time secret before anything else, so a replay with the same Idempotency-Key answers 409 conflict with reason one_time_secret and the idempotency row holds no part of the preview token; the app registers again with a new key, which rotates the token. authorize, the device read and the validation run before the unit of work; the unit of work holds the takeover, the disable and the insert. When a concurrent registration committed the same (provider, token) first, the insert (ON CONFLICT (provider, token) DO NOTHING) returns no row instead of a unique violation, because the handler runs inside the transaction of the Idempotency-Key stage and a failed statement would abort it (the first build answered 500 here, TestRegisterPush_ConcurrentSameToken); the unit of work then runs once more from the takeover with a new preview token, and a second loss answers 409 conflict without a reason. Any other insert error is internal. No durable event and no audit entry. Place: internal/notify/adapters/httpapi/push.go, internal/notify/app/registrations.go, internal/notify/adapters/postgres/registrations_write.go, 13 13. 06 7 and R-API-26: a one-time secret is never stored for a replay; 05 4.9 names no audit and 05 6 no event for push.register; the second run sees the committed row and takes it over, so only a third concurrent writer can make it lose.
D-M3-22-6 2026-10-23 push.register on kind device is visible and allowed only when OwnerID is the principal (another person's device is not_found, not_self); a principal that is not a person in a session (API token, agent, app) gets forbidden with reason step_up_not_possible, evaluated after visibility like the ticket rule of M3-05; a waiting device gets device_pending from the liveness check before, because push.register is not among the actions of a waiting device. push.unregister on the kind push_registration (owner user_id) is visible only to its owner. message.read on kind message and push.deliver on kind thread for a request principal resolve to read on the thread's path. Place: internal/authz/domain/decide.go, internal/authz/domain/visibility.go, 13 13. 03 binds every registration to auth_session_id, and 07 4.1 defines step_up_not_possible as "token without a session"; 05 2.5 and 07 4.1 for waiting devices; 05 4.9 rule self for both actions.
D-M3-22-7 2026-10-23 notify deactivates registrations in the transaction that ends a session or revokes a device, through two in-transaction subscribers registered in internal/wire after the other subscribers of these events: OnSessionEnded disables the active rows of the ended sessions, the session reasons device_revoked and user_disabled giving device_revoked and every other reason (logout, admin, refresh_reuse, inactive and any later one) giving logout; OnDeviceRevoked disables every active row of the device with device_revoked, whatever its session. user.disable reaches both through the events of identity, so it needs no subscriber of its own. A row that is already disabled keeps its first reason; disabled_at is the time the event carries, the clock time when it carries none. A veto later in the unit removes the writes with everything else; a subscriber without its store fails the unit. A session that only expires publishes nothing: its rows stay active, the preview fetch answers 401 session_revoked, and the next registration of that installation takes the token over. No push is sent for a revocation. Place: internal/notify/app/deactivate.go, internal/notify/domain/registration.go, internal/wire/txn.go, 13 7.3. 13 6.4, 13 7.3, 08 10 step 2 and 08 11 step 5: from the commit on the preview token is worthless and no push reaches the device; for a registration "its session ended" is what logout stands for in 13 6.4; 08 10 step 4 and D-W-108 point 7: no registration is left to wake the app.
D-M3-22-8 2026-10-23 getPushPreview checks in this order, the first failure ending the request: the query parameter kind (400 validation_failed, invalid at #query/kind, for security and admin); exactly one Authorization value with the scheme DPoP (401 unauthenticated, WWW-Authenticate: DPoP algs="ES256 EdDSA"); form, kind and checksum of the token without a database call, then the active registration of its HMAC (401 unauthenticated with the challenge invalid_token for a malformed, unknown or deactivated token); identity.Auth.SessionActor of the registration's person and session, which since this ticket also carries KeyThumbprint = the dpop_jkt of the session's active refresh family (a device other than the registration's, also for an unknown session, is 401 unauthenticated; a session without an active refresh family is 401 session_revoked); the DPoP proof against that thumbprint with ath over the preview token (401 invalid_dpop_proof or use_dpop_nonce with DPoP-Nonce; a stale nonce adds the current DPoP-Nonce to the answer); the message (unknown, deleted, run or system: 404 without authorize); then message.read on the message and push.deliver on its thread for the session actor in normal mode, so denials are audited with the person as actor. A decision unauthenticated gives 401 session_revoked, every other denial 404 not_found, identical to an unknown message. auth.logout, device.revoke and user.disable deactivate the registration in their own transaction (D-M3-22-7), so after them the token already fails at the registration lookup; the session and rights checks catch what ends while the row stays active, for example an expired session. Place: internal/notify/app/preview.go, internal/identity/app/sessionactor.go, 13 6.4. The fetch is a request of the person, signed with the device key of the session that registered, so the rules of a request in a session apply (owner rights only in a session, R-AZ-14) and a revoked session, device or person stops the token at once (13 6.4); 401 belongs to the credential and 404 to rights and messages, so a revocation between send and fetch is indistinguishable from an unknown message (P3); the kind needs no credential to be judged.
D-M3-22-9 2026-10-23 The required query parameter kind (NotificationKind, copied by the app from k of the payload) accepts only the kinds that point at a message (message, mention, dm, run_done, run_failed, input_needed) and is echoed. Always: kind, thread_id and message_id of the message, and badge = BadgeCounter.For of the person at the time of the fetch. The mode is the stored notify.preview_mode; an unset value or one that is not exactly full, names or none counts as notify.default_preview_mode. names and full add space_name, channel_name and thread_title (each absent when the place has none, a DM channel has neither space nor channel name) and sender_display_name; full adds excerpt (absent when empty), attachment_count (absent when zero) and image_url, a fetch ticket of the session actor for the thumbnail small of the first attachment in position order whose MIME type starts with image/; any error of the ticket leaves image_url out and never fails the preview. One PreviewBuilder serves the endpoint and step 1 of notify.gateway_send: after its push.deliver check (or R-AZ-30), for a kind with a message and a set message id, the job builds the same fields by the recipient's mode, without message.read and never with image_url; none, an unknown, deleted, run or system message build nothing, and the event still goes out; a failed read fails the job, which the queue runs again. This supersedes D-M3-23-8. Place: internal/notify/app/previewbuilder.go, internal/notify/app/preview.go, internal/notify/app/gatewaysend.go, internal/notify/domain/preview.go, 13 6.3 and 6.4. The server keeps no record of a hint (R-PN-09), so neither nid nor the message tells the kind; the kind selects only labels on the device. The gateway job evaluates the recipient without a request (R-AZ-29, 05 5), the message lies in the thread it just checked, and the hub checks push.deliver again before the frame leaves; a fetch ticket is bound to a session (12 9.2), which the job does not have, and 13 6.3 allows the preview fields without requiring each one.
D-M3-22-10 2026-10-23 domain.Excerpt turns content_md into the excerpt: an @handle at the start of the text or after an ASCII character that is not a letter, a digit or one of _.-@/, whose handle (without regard to case) belongs to a principal of the message's mentions rows, becomes that principal's display name, any other @ text and a mention inside code stay; fence lines are dropped and code kept as it is, inline code backticks and paired emphasis, strong and strike markers are removed (unpaired markers stay), a link becomes its text, an image its alt text, an autolink in angle brackets its address, leading heading, quote, list and task markers, thematic breaks, table separator rows and pipes are removed, backslash escapes are resolved, HTML stays literal; control and bidi control characters are removed, every run of white space becomes one space and the result is trimmed; above notify.preview_max_chars code points it is cut to that number minus one, trailing spaces removed, plus U+2026, never splitting a code point. Place: internal/notify/domain/excerpt.go, 13 6.4. Only the first eight times notify.preview_max_chars code points of content_md are parsed (excerptSource), and the key allows at most 1000: the scans of links, code spans and delimiter runs look ahead within a line, so a long line of [ or backticks made one excerpt quadratic over the whole message (about 14 s of CPU for 256 KiB per recipient, found by the review and the red team of M3-22); a construct cut at the bound stays as literal text (TestExcerpt_ParsesABoundedPrefix). 13 6.4: "text without Markdown, mentions as names, at most notify.preview_max_chars"; bidi controls could reorder the visible text of a notification; no Markdown-to-plain-text function existed in a port notify may use.
D-M3-22-11 2026-10-23 getPushPreview has the security scheme pushPreviewToken instead of dpopAuth. The route generator sets routes.Operation.OwnCredential exactly for an operation whose only security requirement is pushPreviewToken; such an operation is neither public nor an override. StageAuthenticate calls neither the precheck nor the Authenticator for such a route: it sets the anonymous actor and stores the request's actor.Credentials with actor.WithCredentials, which the handler reads with actor.CredentialsFrom. StageRateLimit therefore takes the rl.read token from the bucket of the client IP, and a failed preview credential is not counted against rl.public_auth. StageValidate checks no security requirement and needs no change. Place: api/routes/gen/spec.go, api/routes/routes.go, internal/platform/httpserver/stages.go, internal/kernel/actor/actor.go, docs/api/openapi.yaml. Every other route is unchanged, so a preview token anywhere else gets 401 from the Authenticator; the token has 256 random bits and a checksum, like the tickets of M3-05, so guessing is no threat a failure counter must stop.
D-M3-22-12 2026-10-23 bothy.push.fetch (counter, unit {notification}, attribute bothy.push.provider) counts one per preview request whose token identified an active registration, with that registration's provider, right after the registration is found and whatever the answer after that (200, 404, or 401 from the session, proof or rights check); a malformed or unknown token counts nothing. Logs, spans and errors of the registration and the preview never carry a device token, a preview token, the Authorization or DPoP header, a ticket URL, a name or a text; a failed image ticket other than a missing or unready thumbnail writes one Warn line with error.type only. Place: internal/platform/otel/metrics/catalogue.go, internal/notify/adapters/otelmetrics/metrics.go, internal/notify/app/preview.go, docs/observability/signals.md 4.4. The fetch is the receipt t2 of 13 9 and the numerator of the confirmation rate; without a known provider there is nothing to attribute. The server keeps no hint record, so it measures no t2 minus t0 latency. SEC-LOG-3, SEC-LOG-4, SEC-SEC-3.