M3-22: Push registration and preview endpoint (ADR 0031)¶
Status: ready | Depends on: M3-05, M3-06, M3-07, M3-20, M3-21, M3-23 | Milestone: M3
Goal¶
After this ticket a person's app registers the push target of its own active device with registerPush and gets, exactly once, a preview token that is bound to that registration, to the session that registered it and to the device key; the server stores only its HMAC in push_registrations.preview_token_hash (13 6.4, R-PN-07). A known (provider, token) is taken over by the new registration, because tokens move between installations (13 13). unregisterPush removes a registration of the person. The Notification Service Extension (M5-04) and the Android push service (M5-05) load the content of a hint from GET /api/v1/push/preview (getPushPreview) with Authorization: DPoP <preview token> and a DPoP proof of the device key, never with a refresh token: the server checks token, registration, session, device and person, then message.read on the message and push.deliver on its thread with the rights of the moment of the fetch (13 1 point 2, 05 4.9 row push.deliver, ADR 0031). A revocation between send and fetch gives 404, identical to an unknown message; notify.preview_mode decides what the answer carries (full: names, excerpt and the first image as a fetch ticket of M3-05; names: names only; none: no names, only kind, thread, message and badge). The same preview builder fills the preview fields of the ephemeral event notification.created (13 6.3) in the job notify.gateway_send of M3-23, by the recipient's notify.preview_mode. auth.logout, device.revoke and user.disable deactivate the registrations in their own transaction through the in-transaction events identity.SessionEnded and identity.DeviceRevoked (disabled_reason logout or device_revoked): from the commit on the preview token is worthless and the job notify.push_send of M3-19 sends nothing to that device (13 14 row "auth.logout, device.revoke, user.disable", ID-T22 and ID-T26 for their push step). The registrations of a device appear in the API schema Device. The canary scenario of M1-69 carries a push token and a preview token, so neither ever reaches a log, a span, a metric, an audit row or a problem body (SEC-T10). This closes the server side of "Push without content" in M3: the hint pipeline (M3-19) and the providers (M3-20, M3-21) now have targets that apps can create, and a content path that never passes Apple or Google.
Read first¶
docs/spec/13-push-notifications.md: 1 "Goals" (points 1, 2 and 5); 4.6 "Delivery path per device" (point 2: only registrations withdisabled_at IS NULL); 5.1 (paragraphs "Notification Service Extension", "No token renewal in the NSE", "Device key"); 5.2 "Time budget"; 6.1 and 6.2 (the keysk,nid,t,m,badge; the sync formk = "sync"); 6.3 "Gateway eventnotification.created" (the preview fields); 6.4 "Preview endpoint and preview token" (all of it, with the table and the sequence diagram); 7.3 "Common" (point "Device revoked"; device tokens never in logs); 8 "Badge and sync"; 9 "Delivery measurement" (row t2, "Confirmation rate"); 10 "Privacy"; 13 "Endpoints and actions" (the table and the paragraph onpush.register); 14 "Tests" (rows "Revocation between event and sending", "auth.logout,device.revoke,user.disable"); 15 R-PN-07; 17 (notify.default_preview_mode,notify.preview_max_chars).docs/spec/03-data-model.md(core contract, read only):push_registrations(all columns, the two unique keys, the checks, retentionsecurity);devices(platform,state);device_keys(purposedpop);auth_sessions(revoke_reason,client_component"the Notification Service Extension has no session, it uses the preview token");refresh_token_families.dpop_jkt;messages(kind,content_md,deleted_at,has_attachments);mentions;principals(status,handle,display_name); 6.3 "Lock order"; principle 9 (secrets only as HMAC).docs/spec/05-actions.md(core contract, read only): 2.2 "Target of a resource"; 2.5 "Decision"; 4.9 rowspush.register,push.unregister,push.deliver; 4.7 rowmessage.read; 5 "Check without a request" (to see why the preview fetch is not such a check: it is a request of the person; and that the gateway path of Design note 9 f may check onlypush.deliverwithout a request); 6 rownotification.created; R-AZ-11, R-AZ-29, R-AZ-30, R-AZ-31.docs/spec/08-identity.md: 7.1 "Overview" (token table), 7.3 "DPoP", 7.4 (paragraph "Extension sessions": the NSE has no session), 10 "Lost device" (step 2 "push registrationsdisabled_reason = 'device_revoked'", step 4 "No push is sent for the revocation", D-W-108), 11 "Disabling a person" (step 5), 12 "API tokens for agents, apps and tools" (form), 15 rows ID-T22, ID-T26.docs/spec/07-permissions.md: 4.1 "Decisions and reasons" (not_self,device_pending,step_up_not_possible,session_revoked,principal_inactive,device_inactive), 4.2 "Flow", 4.3, 8.1 "Delivery filter" (row "Push"), 8.2, 11 (AZ-P4).docs/spec/06-api.md: 3 "Authentication per principal kind" (3.1 "DPoP"), 5 "Errors" (points 3, 5 and 7;errors[].codevocabulary), 7 "Idempotency" (row "POST with a one-time secret in the response", R-API-26), 9 "Rate limits".docs/spec/02-architecture.md: 4.3 rowsidentity(DeviceRevoked,SessionEnded, declared source ports),notify(portPushRegistrations, subscriptionsidentity.DeviceRevokedandidentity.SessionEnded),files; 4.4 "Allowed dependencies" (rownotify, "Tiers"); 4.5 (kernel/actor); 5 "Layers within a module"; 8.1 to 8.3 (unit of work, in-transaction events); 9.1 "Chain in the api node"; 9.3 "Proof of the check".docs/spec/12-files.md9.2 "File tickets" (theimage_urlof the preview is such a ticket); 10 "Attaching and agent invocation" (message_attachments); 7.3 "Thumbnails" (the variantsmall).docs/spec/19-security.md: SEC-LOG-3, SEC-LOG-4 (push tokens never in logs), SEC-SEC-3, SEC-SEC-4, "14. Tests" (SEC-T10).docs/api/openapi.yaml: operationsregisterPush,unregisterPush,getPushPreview,listMyDevicesand every other operation that returnsDevice; schemasPushRegistration,PushRegistrationCreate,PushProvider,PushEnvironment,PushPreview,NotificationKind,Device,Problem,FieldError; parametersIdempotencyKey,RegistrationIdPath; responsesUnauthorized,Forbidden,NotFound,BadRequest; headersDPoP-Nonce,WWW-Authenticate; security schemesdpopAuth,bearerAuth,fileTicket;x-problem-codes.- ADRs 0031 (push without content), 0056 (native extensions without the Flutter engine, point on the preview fetch), 0061 (token format: form, check order, storage, scanner), 0017 (one
authorize,404instead of403), 0020 (device binding, DPoP), 0048 (fetch tickets), 0053 (no JSONnull), 0008 and 0050 (hexagonal modules, module register). docs/DECISIONS.md: D-LS-13, D-PN-05, D-K-G9, D-K-X10, D-W-100, D-W-104 (R-AZ-25 to R-AZ-29), D-W-108 (this ticket's share of the push block, R-AZ-30, R-AZ-31), section 10 "Rulings of the build milestones" (format of the rowsD-M3-22-<n>).docs/security/threat-model.md5.12 "Push", T-PU-01, T-AZ-03, T-LG-01.docs/operations/configuration.md:auth.token_pepper_ref,notify.default_preview_mode,notify.preview_max_chars,push.apns.topics,push.apns.team_id,push.apns.key_id,push.fcm.project_id,auth.dpop_proof_max_age,auth.dpop_nonce_ttl; section 5 "Secrets" (token_pepper).docs/observability/signals.md4.4 rowbothy.push.fetch, 5.3 attributebothy.push.provider, 7 "Logs".docs/PLAN.md: 3 "Way of working per ticket"; 9 "M3 Platform services" ("Goal", "Exit criteria", "Red team": content in push payloads, T-PU-01, T-AZ-02 for push); the rows M3-05, M3-19 (table, read port, job, provider port, badge), M3-20, M3-21 (adapters and theirEnabledrules), M3-22 (this ticket, the contract), M3-06 and M3-07 (thumbnails and attachments of the full preview), M3-23 (the jobnotify.gateway_sendwhosenotification.createdthis ticket fills with preview fields), M3-24 (beside it), M5-04 and M5-05 (the consumers of the preview endpoint); 7 rows M1-34, M1-44, M1-45, M1-69, M1-70, M1-71.- Dependencies: the header names M3-05, M3-06, M3-07, M3-20, M3-21 and M3-23 (D-W-108 points 7 and 8). Their closure brings everything this brief uses: M3-23 (
notify.gateway_send,notification.created), M3-07 (message_attachments, thefilesportAttachments), M3-06 (thumbnails, variantsmall), M3-19 (through M3-20, M3-21 and M3-23), M3-18, M3-17, M3-04, M3-03, M3-01, M2-11, M2-10, M2-09, M2-01, M1-58 (deletion), M1-71, M1-70, M1-69, M1-68, M1-66, M1-65, M1-64 (wstest), M1-63, M1-61, M1-60, M1-56, M1-55, M1-53, M1-52, M1-49, M1-48, M1-45 (DeviceRevoked, the disable and revoke use cases), M1-44 (domain/apitoken, portTokens), M1-38, M1-37, M1-35, M1-34 (Auth,SessionEnded, the Authenticator, logout), M1-33 (Devices,DPoP), M1-28, M1-26 (Directory), M1-20 (MarkOneTimeSecret), M1-19, M1-18, M1-17, M1-16, M1-13, M1-12, M1-10, M1-09, M1-07, M1-06, M1-05, M1-03, M1-02, M1-01. Not in the closure: M2-41 (ErrTokenMalformedand the kindhpc), M1-57 (history reads). - Briefs (merged names win, D-W-100): the drafts of M3-19, M3-20 and M3-21 as merged (table
push_registrations, portPushRegistrationswithActive, outgoing portRegistrationStore, the jobnotify.push_sendand its skip reasonno_registration,PushProvider,NotificationwithBadge,HasBadgeand the sync formKindSync,BadgeCounter.Forininternal/notify/app/badge.go(M3-19 Design note 16),fakepush.FakeProvider,apns.Config.Enabled,fcm.Config.Enabled,internal/wire/configkeys_push.go); the draft of M3-23 as merged (internal/notify/app/gatewaysend.go, the jobnotify.gateway_sendwith its argsGatewaySendand steps 1 and 2 (Design note 4),NotificationCreatedPayload(Design note 7), Design note 9 and rulingD-M3-23-8, the live desktop tests withwstest);docs/briefs/M3-07-attachments-invocation.md(portfiles.AttachmentswithForMessages,Attachment,File; every method inside the caller's unit of work, Design note 3);M3-06-image-metadata-thumbnails.md(the jobfiles.thumbnail,VariantSmall);docs/briefs/M3-05-file-download.md(Files.IssueTicket,IssuedTicket,identity.Auth.SessionActor, the session rule for tickets, the route flag precedent of Design note 12),M3-18-notify-preferences.md(sync.Settings.ValuesFor,identity.Directory.Users, theinternal/wire/configkeys_notify.gofile),M3-17-notify-decision.md(TestDomainPurityofinternal/notify/domain),M1-45-identity-revocation.md(DeviceRevoked,SessionEnded, the order ofuser.disable; its Out of scope names this ticket),M1-34-identity-sessions.md(Authenticator steps,Auth.Logout,RevokeSession,SessionEnded),M1-33-identity-devices-dpop.md(Devices,DPoP,ProofRequest,VerifiedProof,DPoPError,ExpectedHTU;Device.push_registrations"omit the member"),M1-44-identity-api-tokens.md(domain/apitoken,testdata/checksum_vectors.json, the redacting secret type, the HMAC with the pepper),M1-20-idempotency-keys.md(one-time secrets),M1-18-http-chain.md(stages,actor.Credentials,errs.WithHeader),M1-17-api-server-stubs.md(route metadata, enable list),M1-28-authz-core.md(requirements, descriptors, deferred entries),M1-26-identity-principals.md(Directory),M1-55-chat-threads.md(Threads.Info,ThreadInfo),M1-56-messaging-send.md(portMessages, mentions),M1-60-messaging-mentions-templates.md,M1-68-core-metrics.md(metric registry),M1-69-canary-test.md(registry, classes, scenario),M1-70-leak-tests.md(TestAZ_P4_RouteCoverage),M1-71-contract-tests.md(G7, G8). - Code on the base branch (all under
services/server/):internal/notify(all of M3-17 to M3-21 and M3-23),internal/identity,internal/files,internal/messaging,internal/chat,internal/sync,internal/authz,internal/kernel/actor,internal/kernel/authorize,internal/kernel/idempotency,internal/kernel/errs,internal/platform/httpserver,internal/platform/otel/metrics,internal/wire,api/routes,api/operations.yaml,test/canary,test/leak,tools/contract,architecture/modules.yaml; at the root.gitleaks.tomlandtools/ci/tests/test_gitleaks_rules.py.
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. Everything this ticket adds to the repository is English; existing text stays as it is.
Product requirements implemented here:
push.registerregisters a push target of the caller's own active device and returns the preview token exactly once; resourcedevice, rightself, no audit,rl.write(05 4.9, 13 13). The row bindsuser_id,device_idandauth_session_idof the caller's session (03push_registrations).- The preview token is issued once, stored only as HMAC in
preview_token_hash, rotated with every new registration and valid only forgetPushPreview(13 6.4, 03 principle 9, R-PN-07); it is DPoP-bound to the device key and has no refresh (R-PN-07, 08 7.4). - A known
(provider, token)is taken over by the new registration, so it stops reaching its former owner (13 13); the unique key(provider, token)of 03 holds. push.unregisterremoves a registration of the caller; a foreign or unknown registration gives the same404(05 4.9, rule 2, B7).- The preview fetch is valid only while the registration is active, the device is
active, the registration's session is not revoked and the person is active (13 6.4); thenauthorize(r, message.read, message)andpush.deliveron its thread with current rights; no right, a deleted or an unknown message gives404(13 6.4, 05 4.9 rowpush.deliver, P3). - The answer follows
notify.preview_modewith the defaultnotify.default_preview_mode:fullwith names, excerpt (plain text, mentions as names, at mostnotify.preview_max_chars), attachment count andimage_url(a fetch ticket forsmallof the first image, 12 9.2);nameswithout excerpt and attachments;noneonlykind,thread_id,message_id,badge(13 6.4 table). auth.logoutdeactivates the registrations of the session withdisabled_reason = 'logout';device.revokedeactivates all registrations of the device withdevice_revoked, at once;user.disabledeactivates the registrations of the person (13 6.4, 13 7.3, 08 10 step 2, 08 11 step 5). Afterwards the preview token is worthless and no push reaches that device (13 14).- Device tokens and preview tokens never appear in logs, spans, metrics, audit details, events or problem bodies (13 7.3, 13 10, SEC-LOG-4, SEC-SEC-3, SEC-T10); the preview fetch is the receipt of 13 9 and counts
bothy.push.fetchper provider. notification.createdcarries the preview fields of 13 6.3 (space_name,channel_name,thread_title,sender_display_name,excerpt,attachment_count,image_url) by the recipient'snotify.preview_mode, built by the same preview builder as the preview endpoint, exceptimage_url, whose fetch ticket needs a session (Design note 9 f);nonegives none of them (13 6.3, 13 6.4 table, D-W-108 point 8).
Scope¶
All paths under services/server/ unless they start with docs/, tools/ or a dot. Where an earlier ticket already created a file named below, extend it. Every new Go file starts with // SPDX-License-Identifier: Apache-2.0 (make licenses). This ticket adds no migration: the table, its unique partial index on preview_token_hash and the index (auth_session_id) exist since M3-19.
Create:
internal/notify/domain/registration.go:ValidateRegistration(in RegistrationInput, pol RegistrationPolicy) []FieldError(Design note 4) andDisableReasonForSession(reason string) string(Design note 7); standard library andkernel-coreonly, soTestDomainPurityof M3-17 passes unchanged.internal/notify/domain/excerpt.go:Excerpt(contentMD string, mentionNames map[string]string, maxChars int) string(Design note 10).internal/notify/domain/preview.go:PreviewMode(full,names,none),ParsePreviewMode(stored string, def PreviewMode) PreviewMode,PreviewKindAllowed(kind string) bool(Design notes 8 and 9).internal/notify/app/registrations.go(use casesRegisterandUnregister),internal/notify/app/preview.go(use casePreview),internal/notify/app/previewbuilder.go(the preview builder shared byPreviewand the jobnotify.gateway_send, Design note 9),internal/notify/app/deactivate.go(the subscribersOnSessionEndedandOnDeviceRevoked),internal/notify/app/pushtargets.go(the implementation of the source portidentity.PushTargets).internal/notify/adapters/postgres/queries/registrations_write.sql:LockRegistrationByProviderToken,DeleteRegistration,DisableOtherRegistrationsOfApp,InsertRegistration,GetRegistrationByPreviewHash,GetRegistrationForOwner,DisableRegistrationsOfSessions,DisableRegistrationsOfDevice,ListRegistrationsOfDevices(Design notes 5 to 7 and 11), then sqlc.internal/notify/adapters/httpapi/push.go: the strict-server methodsRegisterPush,UnregisterPush,GetPushPreview(Design note 12).
Change:
internal/notifyport (root package): add toPushRegistrationsof M3-19Register(ctx context.Context, a actor.Actor, cmd RegisterCmd) (RegisteredPush, error),Unregister(ctx context.Context, a actor.Actor, registrationID ids.ID) errorandPreview(ctx context.Context, req PreviewRequest) (PreviewView, error)withRegisterCmd{Provider, Token, Environment, AppID string},RegisteredPush{Registration PushRegistration; PreviewToken <the redacting secret type of M1-44>},PreviewRequest{Credentials actor.Credentials; NotificationID, MessageID ids.ID; Kind string}andPreviewViewwith the fields of the schemaPushPreview. Doc comment on each.internal/identity(additive): the kindpvwindomain/apitoken(Design note 3); the portTokensgetsNewPushPreviewToken(ctx context.Context) (PushPreviewToken, error)withPushPreviewToken{Secret <redacting type>; Hash []byte}andHashPushPreviewToken(ctx context.Context, raw string) ([]byte, error)returningErrTokenMalformed; the Authenticator rejects the kindpvwwithout a database call on every operation;Auth.SessionActorof M3-05 also fillsKeyThumbprintwith thedpop_jktof the session's family (Design note 8); the declared source portPushTargetsand the fieldPushRegistrationsof theDeviceDTO filled through it (Design note 11).testdata/checksum_vectors.json: one vector of kindpvwwith the random partEXAMPLEpushpreviewtoken00000000000000000000and the checksum4Z1L0A(computed independently for this brief with the method that reproduces thehpcvector4SigQvof M2-41; the random part carries theEXAMPLEmarker).internal/messagingport and adapter (additive):Messages.ForPreview(ctx context.Context, messageID ids.ID) (PreviewMessage, bool, error)(Design note 9), unless the merged port already returns one message with these fields.internal/chatport and adapter (additive):Structure.PreviewNames(ctx context.Context, threadID ids.ID) (PreviewNames, error)withPreviewNames{SpaceName, ChannelName, ThreadTitle string}(empty when the structure has none), unlessThreads.InfoorStructurealready return these names.internal/notify/app/gatewaysend.go(M3-23): step 1 of the jobnotify.gateway_sendcalls the preview builder and step 2 puts its fields intoNotificationCreatedPayload(Design note 9 f).internal/authz: the requirements ofpush.registerandpush.unregister, the visibility of the kindpush_registrationand the session rule (Design note 6); check the merged requirement ofmessage.readandpush.deliveragainst Design note 8.internal/kernel/actor(additive, only if absent):WithCredentials(ctx context.Context, c Credentials) context.ContextandCredentialsFrom(ctx context.Context) (Credentials, bool)(Design note 12).api/routes(M1-17) andStageAuthenticateandStageValidateofinternal/platform/httpserver(M1-18): the route flagOwnCredentialfor operations whose only security scheme ispushPreviewToken, and the stage behaviour of Design note 12; regenerate,make codegen-check.api/operations.yaml: moveregisterPush,unregisterPushandgetPushPreviewfromlatertoenabled; rerun the route generator.internal/wire: the policy of Design note 4 fromapns.Config,fcm.Configandpush.apns.topics; the in-transaction subscriptionsidentity.SessionEnded -> notify.OnSessionEndedandidentity.DeviceRevoked -> notify.OnDeviceRevoked;notifyas the implementation ofidentity.PushTargets(replacing the empty source); thefilesportsFilesandAttachmentsand theidentity.Tokens,identity.DPoP,identity.Authports fornotify; the three handlers; regeneratetestdata/txn_registrations.golden.architecture/modules.yaml:filesinnotify.depends_on; regenerate.go-arch-lint.yml(cd services/server && go generate ./tools/archgen).internal/platform/otel/metrics: theSpecofbothy.push.fetch(make signal-check).docs/api/openapi.yaml: Design note 13.docs/spec/13-push-notifications.md(with 6.3, Design note 9 f),docs/spec/08-identity.md,docs/spec/02-architecture.md,docs/adr/0061-token-format-with-a-fixed-prefix-and-checksum.md,docs/architecture/c4-3-server-components.mmdand its.svg(make diagrams),docs/security/threat-model.md,docs/observability/signals.md: Design note 14..gitleaks.tomlrulebothy-api-token: the kindpvwin the regex and the keywordbothy_pvw_;tools/ci/tests/test_gitleaks_rules.py: a case that generates apvwtoken (secrets.token_bytes(32), Base62,zlib.crc32) and asserts the rule fires.test/canary,test/leak,tools/contractand the G8 validator (M1-69, M1-70, M1-71): Design note 15.- READMEs of
notify,identity,messaging,chat,authz,internal/platform/httpserverandapi;services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M3-22-1toD-M3-22-12(Design note 16).
This ticket adds no table, no migration, no action, no durable or ephemeral event type, no problem code, no job kind and no top-level Makefile target. It adds the configuration key push.registration_field_max_bytes (13 17 and the reference in the same change), the token kind pvw (ADR 0061 in the same change), the security scheme pushPreviewToken, the schema PushRegistrationCreated, the query parameter kind of getPushPreview, the reason value push_provider_disabled (13 in the same change) and the declared source port identity.PushTargets (02 in the same change).
Out of scope¶
- The table
push_registrations, its migration, the read portPushRegistrations.Active, the outgoing portRegistrationStore, the jobnotify.push_sendwith its skip reasons, the result classes that disable a registration after provider answers (token_invalid,topic_mismatch,sender_mismatch,repeated_failures), the badge count and the badge sync of 13 8: M3-19 (D-W-108). This ticket only reads the badge for the preview answer. - APNs and FCM payloads, headers and answers: M3-20 and M3-21. This ticket reads only their
Enabledrules andpush.apns.topics. notification.createdover the gateway, the jobnotify.gateway_send, presence and the delayed push: M3-23; this ticket only fills the preview fields of that event (Design note 9 f). The measurement ofperf.push_handoff_p95: M3-24.- A push for a device revocation: 08 10 step 4 says that no push is sent for the revocation, because step 2 disables the device's registrations in the same transaction (D-W-108 point 7); nothing to build (Design note 7 d).
- The Notification Service Extension, the Android push service, the shared keychain and the placeholder on the device: M5-04, M5-05.
- Attaching files to a message (
message_attachments, thefilesportAttachments) and rendering thumbnails: M3-07 and M3-06; this ticket only reads them for the preview (Design note 9 c). - Export and deletion of
push_registrationsfor privacy requests and the retention classsecurity: M7. - WNS, UnifiedPush and a push relay: later (13 5.3, 5.4, 11, R-PN-10).
- Changes to the core contract.
Boundaries¶
- This ticket creates no migration; if the merged base lacks
push_registrationsor one of its columns or indexes of 03, stop withVerdict: BLOCKED <missing object>(M3-19 owns it, D-W-108). - Work in a dedicated git worktree on the branch
feat/M3-22-push-registration; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(notify): ...,feat(identity): ...) 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 -dort.TempDir(), removed at the end. - Never read or print secrets. Tests never print a device token, a preview token, a ticket or the pepper (assert on them, log only their length). No network calls except package downloads for pinned tools and Go modules.
- No real push service, no Apple or Google account and no real device token in any test: providers are
fakepush.FakeProviderof M3-19, tokens are generated per run (EXAMPLEmarker where a value is committed); nothing in this ticket costs money. - Never lower a threshold, disable a gate or delete a test.
- Time only through
kernel/clock; tests move a fake clock. - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). The takeover of a known token (Design note 5 c) and the mapping of session ends todisabled_reason(Design note 7) are settled in this brief within the existing columns and checks of 03 and are no reason to stop. - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 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 the merged code of M3-05 and M3-17 to M3-21, whose briefs were written in parallel with this one: follow their names in
internal/notify,internal/filesandinternal/identityand list each difference.
Design notes¶
-
Layers and dependencies (02 4.3, 4.4, 5; ADR 0008). The adapter
internal/notify/adapters/httpapimaps request and response objects and holds no rule; the use casesRegister,Unregister,Previewand the two subscribers live ininternal/notify/app; validation, the session mapping, the preview mode and the excerpt are pure functions ininternal/notify/domain. Every write ofpush_registrationsstays in thenotifypostgres adapter; the outgoing portRegistrationStoreand the job of M3-19 do not change.notifyreachesidentity(Tokens,DPoP,Auth.SessionActor,Devices,Directory),chat,messagingandsyncthrough their ports, as 02 4.4 allows, andfiles(Files.IssueTicketof M3-05,Attachments.ForMessagesof M3-07) through a new edge. Ruling for docs/DECISIONS.md: addfilesto "May use" ofnotifyin 02 4.4 with the reason "attachments and fetch tickets for the preview" (English), tonotify.depends_oninarchitecture/modules.yaml, the edgenotify --> filestodocs/architecture/c4-3-server-components.mmdand to the dependency line of thenotifyREADME; the edge points down (02 4.4 "Tiers"), andmake register-checkkeeps the four equal.identitynever importsnotify: the registrations of a device reach theDeviceschema through the source portidentity.PushTargetsthatidentitydeclares andnotifyimplements (Design note 11), the pattern ofidentity.AvatarFiles. -
Observability (signals.md 4.4, 13 9, 17; Ruling for docs/DECISIONS.md). Declare
bothy.push.fetchas aSpecin the registry of M1-68 (counter, unit{notification}, attributebothy.push.provider, roleapi) and count one per preview request whose token identified an active registration (steps a to c of Design note 8 passed), whatever the answer after that (200,404, or401from the session check), with the registration'sprovider. A malformed or unknown token counts nothing, because no provider is known. This is the receipt t2 of 13 9 and the numerator of the "Confirmation rate"; the server keeps no hint record, so it measures no t2 minus t0 latency (report item). Logs, spans and errors of this ticket never carry a device token, a preview token, theAuthorizationorDPoPheader, a ticket URL, a name or a text (SEC-LOG-3, SEC-LOG-4, SEC-SEC-3); span attributes onlybothy.push.provider; error texts are fixed strings. -
The preview token (13 6.4, ADR 0061, 08 12; Ruling for docs/DECISIONS.md). Form
bothy_pvw_<random>_<checksum>fromdomain/apitokenof M1-44 with the new kindpvw: 256 bits fromcrypto/randin Base62 as exactly 43 characters, CRC-32 (IEEE) over the random part as exactly 6 Base62 characters.Tokens.NewPushPreviewTokenreturns it in the redacting secret type of M1-44 withHash= HMAC-SHA-256 with the pepper ofauth.token_pepper_refover the full token string, the derivation ofapi_tokens.token_hash.Tokens.HashPushPreviewTokenchecks form, kindpvwand checksum and returnsErrTokenMalformedbefore any HMAC for every other value (reuseErrTokenMalformedif the merged code has it, otherwise create it with a doc comment). The Authenticator of M1-34 and M1-44 answers apvwtoken underBearerorDPoPon any operation with401 unauthenticatedwithout a database call, andcreateTokennever derives the kindpvw. Reason for this form and not opaque bytes: the gitleaks rulebothy-api-tokenfinds a leaked preview token (SEC-SEC-4), and the checksum rejects junk before the database (ADR 0061 point 2). The token is valid only throughgetPushPreview(Design note 12) and only as long as its registration row is active; it has no expiry of its own and no refresh (R-PN-07): it ends with its registration, its session, its device or its person. -
Validation and provider policy (13 5.1, 5.2, 5.4, 13 13; 06 5 points 3 and 5; Ruling for docs/DECISIONS.md).
internal/wirebuildsRegistrationPolicy{Enabled map[string]bool; APNsTopics []string; FieldMaxBytes int}fromapns.Config.Enabled()(M3-20),fcm.Config.Enabled()(M3-21),push.apns.topicsandpush.registration_field_max_bytes, in every role, without building an adapter or reading a secret (the roleapiservesregisterPush). Afterauthorize(Design note 5 a), in this order:a. Provider
unifiedpushorwns:400 validation_failedwitherrors[].codenot_enabledat/provider(13 5.4: no adapter in this server version).b. Provider
apnsorfcmthat is not enabled on this instance:403 feature_disabledwithreasonpush_provider_disabled(06 5 point 5: a switched-off function answers authenticated principals so that the app can show why).c. Field rules, all collected into one
400 validation_failed: the device'splatformmust beiosormacosforapnsandandroidforfcm, otherwiseinvalidat/provider;environmentabsent meansproduction, andsandboxwithfcmisinvalidat/environment(only APNs has two environments, 13 5.1); anapnstoken consists only of hexadecimal digits in an even number and is stored in lower case (the form M3-20 sends in the path), anfcmtoken only of printable ASCII without white space, otherwisepattern_mismatchat/token;tokenandapp_idlonger thanpush.registration_field_max_bytesaretoo_long; anapnsapp_idmust be inpush.apns.topics, otherwiseinvalidat/app_id(M3-20 would disable it astopic_mismatchat the first send).The new configuration key
push.registration_field_max_bytes(integer, default1024, minimum64, maximum2048, so a token always fits the B-tree entry of the unique key(provider, token)): ininternal/wire/configkeys_push.go, in 13 17 and throughmake config-referencein the reference. Reason: the schema has no length for both fields, and an oversized token would fail the unique index with an internal error. -
registerPush(13 6.4, 13 13, 06 7; Ruling for docs/DECISIONS.md), one unit of work:a.
authorize(actor, push.register, device {ID: actor.DeviceID, OwnerID: actor.ID})with the rules of Design note 6; thenidentity.Devices.Getfor the device'splatform; then Design note 4.b. Lock order of 03 6.3: the registration rows of one statement in ascending
id.c. Takeover:
LockRegistrationByProviderToken(provider, token)(FOR UPDATE); a row, active or disabled and of any person, is deleted (DeleteRegistration). 13 13 says "the old row is disabled", but the unique key(provider, token)of 03 covers disabled rows too, so an old row and the new one cannot both exist; deleting has the effect of deactivating for the former owner (no push, its preview token hash is gone) and keeps no device token in two places. Write this into 13 13 (English). Pendingnotify.push_sendjobs of the deleted row end withno_registration(M3-19).d. One current token per app installation:
DisableOtherRegistrationsOfApp(device_id, provider, app_id)setsdisabled_at = nowanddisabled_reason = 'token_invalid'on the other active rows of this device with the same provider andapp_id, because a provider gives one installation one current token and the app reports the new one; otherwise the device would get every hint twice until the old token fails.e. Mint the preview token (Design note 3) and
InsertRegistration:idUUIDv7,user_id=actor.ID,device_id=actor.DeviceID,auth_session_id=actor.SessionID,provider,token,environment,app_id,preview_token_hash,created_at= now;failure_count0.f.
idempotency.MarkOneTimeSecret(ctx)and201withPushRegistrationCreated(the registration andpreview_token). A replay with the sameIdempotency-Keyanswers409 conflictwithreasonone_time_secret(06 7, R-API-26); the app then registers again with a new key, which rotates the token.g. Two registrations of one
(provider, token)at the same time: the second insert hits the unique key after the first commits; the unit of work runs once more from step c, and a second unique violation answers409 conflict.h. No durable event and no audit entry (05 4.9: Audit "no", no event in 05 6); the person's other clients see the registration in
Device.push_registrationson their next read.i.
unregisterPush:GetRegistrationForOwner(id)givesuser_id;authorize(actor, push.unregister, push_registration {ID, OwnerID: user_id}); unknown and foreign give the same404 not_found; thenDeleteRegistrationand204. A second call answers404. Removing frees the token for a later registration and ends its preview token. -
authz(05 2.2, 2.5, 4.9; 07 4.1 to 4.3; Ruling for docs/DECISIONS.md). Check the merged entries against this list and add what is missing, with a test per line:push.register, kinddevice: visible and allowed only whenOwnerIDequals the principal (self; another person's device isnot_found, 07 4.1not_self); a principal without a session (agents, apps, API tokens) getsforbiddenwithreasonstep_up_not_possible, evaluated after visibility like the ticket rule of M3-05, because 03 binds every registration toauth_session_idand 07 4.1 defines that reason as "token without a session"; a device in statependinggetsdevice_pending(05 2.5;push.registeris not in the list of 07 4.1 for waiting devices).push.unregister, kindpush_registration(new resource kind in the descriptor, owner =user_id): visible only to its owner, otherwisenot_found; requirementself.message.readon kindmessageandpush.deliveron kindthreadfor a request principal: read on the thread's path (05 2.2),not_foundotherwise; if the merged entry of either is missing or deferred, add it here and remove it from the golden list of deferred actions.
-
Deactivation in the revoking transaction (13 6.4, 13 7.3, 08 10 step 2, 08 11 step 5; 02 4.3 row
notify; Ruling for docs/DECISIONS.md). Two in-transaction subscribers, registered ininternal/wireafter the subscribers thatidentityitself needs; a veto later in the unit removes their writes with everything else:a.
OnSessionEnded(ctx, identity.SessionEnded):DisableRegistrationsOfSessions(sessionIDs, domain.DisableReasonForSession(reason), now)on the rows withdisabled_at IS NULL. Mapping onto the six values of 03: the session reasonsdevice_revokedanduser_disabledgivedevice_revoked; every other session reason (logout,admin,refresh_reuse,inactiveand any later one) giveslogout, because for a registration "its session ended" is whatlogoutstands for in 13 6.4. Write the mapping into 13 7.3 (English).b.
OnDeviceRevoked(ctx, identity.DeviceRevoked):DisableRegistrationsOfDevice(deviceID, 'device_revoked', now)on every active row of the device, whatever its session (13 7.3 "all registrationsdevice_revoked, immediately").c.
user.disablepublishesSessionEndedwithuser_disabledand oneDeviceRevokedper device in its transaction (M1-45), so every registration of the person ends there; no own subscriber is needed. A row that is already disabled keeps its first reason. A session that only expires publishes nothing: its registrations stay active, the preview fetch fails at the session check (Design note 8 g), and the next registration of that installation takes the token over (Design note 5 c, d).d. No push for a revocation: 08 10 step 4 "No push is sent for the revocation: step 2 disables the push registrations of the device in the same transaction, so no registration is left to wake the app" (D-W-108 point 7). Nothing to build.
-
Preview authentication and checks (13 6.4, 08 7.3, ADR 0061 point 2, 05 4.9 row
push.deliver; Ruling for docs/DECISIONS.md).Preview, in this order; the first failure ends the request:a. Credentials from the context (Design note 12): exactly one
Authorizationvalue with the schemeDPoP, otherwise401 unauthenticatedwithWWW-Authenticate: DPoP algs="ES256 EdDSA".b.
Tokens.HashPushPreviewToken:ErrTokenMalformedgives401 unauthenticatedwithout a database call.c.
GetRegistrationByPreviewHashwithdisabled_at IS NULL(the unique partial index of 03): no row gives401 unauthenticatedwithWWW-Authenticate: DPoP error="invalid_token". A deactivated or deleted registration answers exactly like an unknown token.d.
identity.Auth.SessionActor(reg.UserID, reg.AuthSessionID)(M3-05). This ticket addsKeyThumbprint= thedpop_jktof the session's family to that actor (additive, with a test), because the proof must be checked against the device key of the session. An actor whoseDeviceIDdiffers fromreg.DeviceIDgives401 unauthenticated.e.
identity.DPoP.Verify(ProofRequest{Proofs, Method, URL, AccessToken: raw preview token, ExpectedJKT: actor.KeyThumbprint})withURLthehtuthe chain computed: aDPoPErrorgives401with its code (invalid_dpop_prooforuse_dpop_nonce) and theDPoP-Nonceheader (errs.WithHeader);NonceStaleaddsDPoP-NoncewithCurrentNonce()to the answer.athbinds the proof to the preview token (RFC 9449), so a proof for another token fails.f. Count
bothy.push.fetch(Design note 2).g.
messaging.Messages.ForPreview(message_id): unknown,deleted_atset, orkind IN ('run','system')gives404 not_foundwithoutauthorize(Ruling: a deleted message answers like an unknown one, 13 6.4). Thenauthorize(actor, message.read, message)andauthorize(actor, push.deliver, thread of the message)in normal mode, so denials are audited with the person as actor (07 4.2). A decisionunauthenticated(principal_inactive,session_revoked,device_inactive) gives401 session_revoked, as the chain maps it for every request (M1-34); every other denial gives404 not_found, identical to an unknown message (status, headers and body exceptcorrelation_id).h. Why the actor of the session and not the principal of R-AZ-29: 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 "Valid only as long as").
-
Preview answer (13 6.4 table, 13 8, 12 9.2; ADR 0053; Ruling for docs/DECISIONS.md):
a.
kindcomes from the new required query parameterkind(NotificationKind), which the app copies from the payload (b.k,data.k, 13 6.1, 6.2);domain.PreviewKindAllowedaccepts only the kinds that carry a message (message,mention,dm,run_done,run_failed,input_needed),securityandadmingive400 validation_failedwitherrors[].codeinvalidat#query/kind. Reason: the server keeps no record of a hint (jobs only, R-PN-09), so neithernidnor the message tells which kind the push had; the kind only selects labels on the device, never content, and the answer is decided bymessage.readalone. The sync form of 13 8 (k = "sync", the iOS push with onlybadge) carries no message and never reaches this endpoint: the NSE does not run withoutalert, andsyncis not aNotificationKind.b. Always:
kind,thread_id(the message's thread),message_id, andbadge= the badge number of 13 8 for the person at the time of the fetch, computed byBadgeCounter.For(ctx, userID)of M3-19 (internal/notify/app/badge.go, M3-19 Design note 16), the same function that fillsBadgeandHasBadgeof the provider port'sNotification(aps.badge,data.badge).c. The mode is
domain.ParsePreviewModeof the storednotify.preview_mode(sync.Settings.ValuesFor) with the defaultnotify.default_preview_mode; a stored value that is notfull,namesornonecounts as unset.namesandfulladdspace_name,channel_name,thread_title(fromchat; absent when empty, for example a DM channel or a main thread without title) andsender_display_name(identity.Directory.Principalsof the author, also for agents and apps).fulladdsexcerpt(Design note 10; absent when empty, for example a voice message),attachment_countandimage_urlfromfiles.Attachments.ForMessagesof M3-07 (called only whenhas_attachmentsis true, inside the read-only unit of work of the use case, because everyAttachmentsmethod runs in the caller's unit of work):attachment_countis the number of attachments (absent when zero),image_urltheurlofFiles.IssueTicket(actor, file, small)of M3-05 for the first attachment in position order whosemime_typestarts withimage/, whosesmallthumbnail M3-06 renders; any error ofIssueTicket(not ready, blocked, nosmallthumbnail yet) leavesimage_urlout and never fails the preview.d. For
run_donethe excerpt is the one of the last agent message and forinput_neededthe one of the question, because the push of M3-19 pointsmat exactly these messages; the endpoint excerpts the message it is given.e. The JSON answer keeps the API headers of M1-18 (
Cache-Control: no-store) and addsDPoP-Noncewhen the proof used the previous nonce.f. Preview fields of
notification.created(13 6.3; D-W-108 point 8; M3-23 Design notes 4, 6, 7, 8 and 9; Ruling for docs/DECISIONS.md).internal/notify/app/previewbuilder.goholds the mode, the names, the excerpt and the attachments of c and d behind one builder thatPreviewand the jobnotify.gateway_sendof M3-23 both call; only the mapping intoPushPrevieworNotificationCreatedPayloaddiffers.- In step 1 of
notify.gateway_send(its unit of work), after itspush.delivercheck (or the R-AZ-30 rule) passed, and only for a kind thatdomain.PreviewKindAllowedaccepts with a setMessageID: read the recipient'snotify.preview_mode(sync.Settings.ValuesFor,domain.ParsePreviewModewithnotify.default_preview_mode);nonebuilds nothing. Otherwisemessaging.Messages.ForPreview(MessageID): unknown,deleted_atset,runorsystembuilds nothing, and the event still goes out with its IDs as M3-23 decides. Then the fields of c fornamesorfull. - Rights: no
message.readhere. The job evaluates the recipient as a human without a request (R-AZ-29), and 05 section 5 lists onlypush.deliverand the R-AZ-30 rule for hints. The message lies in the thread that step 1 has just checked withpush.deliverand the rights of that moment, both resolve to read on the thread's path (Design note 6), and the hub checkspush.deliveragain in log order before the frame leaves (M3-23 Design notes 6 and 8). So a reader who lost access gets no event and no name. image_urlstays absent innotification.created, in every mode: a fetch ticket is bound to a session (12 9.2; M3-05 Design note 7:step_up_not_possiblewithout a session), the job has no session and publishes one event to every live connection of the recipient. 13 6.3 allows the preview fields ("may") and does not require each one; the live client fetches the image with a ticket of its own session.attachment_countis filled forfull.- Step 2 copies the built fields into
NotificationCreatedPayload; absent members are omitted (ADR 0053). Logs and spans of the job never carry a name or text (Design note 2). - In 13 6.3 replace the sentence that M3-23 Design note 9 added ("the preview fields come from the builder of 6.4, which M3-22 adds to
notification.created; until then they are absent and the client shows the hint from its synced data") with these rules (English); rulingD-M3-22-9records that it supersedesD-M3-23-8.
- In step 1 of
-
Excerpt (13 6.4 "text without Markdown, mentions as names, at most
notify.preview_max_chars"; Ruling for docs/DECISIONS.md).domain.Excerptis pure and table-tested; if the merged code already has a Markdown-to-plain-text function for the content dialect in a port this ticket may use, use it and list it. Steps:a. Mentions: every
@handlewhose handle (compared likelower(handle)of 03) belongs to a principal in the message'smentionsrows becomes that principal's display name; any other@text stays.b. Markdown of
content_format1: fence lines are dropped and the code text kept; backticks of inline code, paired emphasis, strong and strike markers are removed; an inline link becomes its link text, an image its alt text, an autolink in angle brackets its address; leading heading, quote and list markers and table pipes are removed; backslash escapes are resolved; HTML stays as literal text.c. Every control and bidi control character is removed, every run of white space (line breaks included) becomes one space, and the result is trimmed.
d. A result longer than
notify.preview_max_charsUnicode code points is cut to that number minus one code point followed by U+2026; no code point is ever split. -
Registrations in
Device(openapiDevice.push_registrations; M1-33 left the member out "(M3)"; 02 4.3; Ruling for docs/DECISIONS.md).identitydeclares the source portPushTargetswithForDevices(ctx context.Context, deviceIDs []ids.ID) (map[ids.ID][]PushTargetView, error)andPushTargetView{ID, DeviceID ids.ID; Provider, Environment, AppID string; CreatedAt, DisabledAt time.Time; DisabledReason string}(no token, no hash);internal/wirewires an empty source untilnotifyimplements it withListRegistrationsOfDevices(one query per response, ascendingid, disabled rows included). TheDevicemapper ofidentityfillspush_registrationsin everyDeviceit returns (listMyDevices,listUserDevices,getDevice,approveDevice,revokeDeviceand every other operation with that schema) and leaves the member out when the device has none (ADR 0053);disabled_atanddisabled_reasononly for disabled rows. The read runs afteridentityauthorized the device, so it calls noauthorize. -
Chain and handlers (02 9.1, 9.3; M1-17, M1-18; the route flag precedent of M3-05; Ruling for docs/DECISIONS.md).
a.
getPushPreviewgets the new security schemepushPreviewTokeninstead ofdpopAuth. The route generator setsroutes.Operation.OwnCredentialexactly for operations whose only security scheme ispushPreviewToken, with a test.b.
StageAuthenticatedoes not call the Authenticator for such a route: it sets the anonymous actor and stores theactor.Credentialsit already builds (method,htu, the values ofAuthorizationandDPoP, client IP) in the context withactor.WithCredentials. Every other route is unchanged, so a preview token on any other operation gets401 unauthenticatedfrom the Authenticator (Design note 3).c.
StageRateLimittherefore sees an anonymous request and takes therl.readtoken from the bucket of the client IP (M1-19). A failed preview credential is not counted againstrl.public_auth: the token has 256 random bits and a checksum, as the tickets of M3-05.d.
StageValidatemust accept a request that carries the preview token; add a test if the validator checks security requirements.e.
RegisterPushmapsPushRegistrationCreatetoRegisterCmdandRegisteredPushtoPushRegistrationCreatedand marks the one-time secret;UnregisterPushanswers204;GetPushPreviewreadsactor.CredentialsFrom(ctx)and callsPreview. Every use case callsauthorize, so the proof of the check of 02 9.3 holds. -
docs/api/openapi.yaml(spec first, rule 10):components.securitySchemes.pushPreviewToken:type: http,scheme: DPoP, description: the preview token of a push registration with a DPoP proof of the device key; onlygetPushPreviewaccepts it (13 6.4, R-PN-07).registerPush:201with the new schemaPushRegistrationCreated(allOfofPushRegistrationand an object with the requiredpreview_token, described as shown exactly once and never again); add409(Conflict: replay of the one-time secret, concurrent takeover); the description names the takeover, the one current token per app installation,403 feature_disabledwithreasonpush_provider_disabled,403 forbiddenwithreasonstep_up_not_possibleand403 device_pending.PushRegistrationCreate: descriptions oftokenandapp_idnamepush.registration_field_max_bytesand the per-provider rules of Design note 4.unregisterPush: description (removes the row; foreign and unknown give the same404).getPushPreview: securitypushPreviewToken; the required query parameterkind(NotificationKind, description of Design note 9 a);DPoP-Nonceon200; the description lists the order of Design note 8 and which failures give401and which404.Device.push_registrations: description (all registrations of the device, without tokens).- Run
make api-lint,make api-checks,make api-tables,make codegen-checkandmake api-clients.
-
Specification and documents (rule 10; nothing in 00, 03, 04 or 05 changes; all new text English, existing text stays as it is):
docs/spec/13-push-notifications.md: 6.3, the rules of Design note 9 f in place of the sentence of M3-23; 6.4, a paragraph after the first list: token formpvw, the check order of Design note 8,401for token, session, device and person,404for rights and messages, the parameterkind, the mode rules of Design note 9 c and the excerpt of Design note 10; 7.3, a sentence with the session mapping of Design note 7 a; 13, a paragraph: takeover by deletion, one current token per app installation, the provider and field rules of Design note 4 withpush_provider_disabled,unregisterPushdeletes; 17, the rowpush.registration_field_max_bytes.docs/spec/08-identity.md7.1: a row "Preview token (push)" (formbothy_pvw_..., bound to the registration, its session and the device key, lifetime of the registration, stored as HMAC-SHA-256).docs/spec/02-architecture.md4.3: rowidentity"declaresPushTargets", rownotify"implementsidentity.PushTargets"; 4.4 rownotify(Design note 1).docs/adr/0061-token-format-with-a-fixed-prefix-and-checksum.mdpoint 1: a supplement sentence with the kindpvw(preview token of a push registration, 13 6.4), like the supplement forhpc.docs/architecture/c4-3-server-components.mmd: the edgenotify --> files;make diagrams.docs/security/threat-model.md5.12: rowsS | Preview token stolen from the shared keychain | DPoP binding to the device key, ends with registration, session and device, message.read at each fetch | T-AZ-03,I | Push or preview token in logs | never logged, canary test SEC-T10 | T-LG-01,T | Someone registers a foreign device token | the token is a device secret that the server never returns; takeover only moves IDs without content (ADR 0031) | T-PU-01.docs/observability/signals.md4.4 rowbothy.push.fetch: what counts (Design note 2).docs/operations/configuration.mdthroughmake config-reference.
-
Suites over every enabled operation (M1-69, M1-70, M1-71, all in this closure):
- Canary (
test/canary, SEC-T10, PLAN row "the canary scenario contains a push token"): the scenario registers a push target of Alex's phone with a device token that contains a canary value of classsecret(providerfcm, so the hexadecimal rule ofapnsdoes not apply), registers the issued preview token as a canary of classsecret(allowed only in theregisterPushresponse that issues it and in thegetPushPreviewrequests that carry it), fetches a preview with it, readslistMyDevices, unregisters, and sendsAuthorization: DPoP bothy_pvw_<canary>as an invalid value.TestSEC_T10_ScenarioCoversEveryEnabledOperationandTestSEC_T10_CanaryAcrossAllOutputspass. - Leak (
test/leak, AZ-P4):TestAZ_P4_RouteCoveragecoversunregisterPush(a registration of Alex for Sam against a random id) andgetPushPreview(Sam's valid preview token for a message in a channel Sam cannot read against a random message id): identical status, headers and body. - Contract (
tools/contract, G7 and G8): the harness knows the schemepushPreviewToken(a registration with a fresh token and a proof per request); the three operations pass G7 in both passes and every integration response of this ticket passes G8.
- Canary (
-
Rulings for
docs/DECISIONS.md(section 10, one row each with date, decision, reason and place):D-M3-22-1the edgenotify -> filesand the source portidentity.PushTargets(notes 1, 11);D-M3-22-2the token formpvw, its HMAC and its lifetime (note 3);D-M3-22-3provider and field rules,push_provider_disabled,push.registration_field_max_bytes(note 4);D-M3-22-4takeover by deletion and one current token per app installation (note 5 c, d);D-M3-22-5one-time secret replay and the concurrent takeover (note 5 f, g);D-M3-22-6the session rule ofpush.registerand the resource kindpush_registration(note 6);D-M3-22-7deactivation by in-transaction subscribers and the session reason mapping, expiry without deactivation (note 7 a to c);D-M3-22-8the preview check order,401versus404, the session actor withKeyThumbprint(note 8);D-M3-22-9the parameterkind, the fields per mode and the preview fields ofnotification.created, supersedingD-M3-23-8(note 9);D-M3-22-10the excerpt rules (note 10);D-M3-22-11the route flagOwnCredential, rate limiting by client IP (note 12);D-M3-22-12whatbothy.push.fetchcounts (note 2). Rulings 3, 4, 7, 8, 9 and 10 also go into 13 (note 14).
Tests (write them first)¶
Test world: 07 10 and 11.1 (space Support with channels Allgemein and Eskalation; Alex with trigger on Support; group Leser with Sam and read on Support/Allgemein; Marketing with Kampagnen, which Sam reads and Alex does not; Robin without a grant on Support; the Owner). Devices, sessions, device keys and DPoP proofs come from the identity test helpers: Alex has an iPhone (ios) and an Android tablet (android), Sam an Android phone, each with an app session. Configuration in tests: push.apns.team_id = EXAMPLETEAM, push.apns.key_id = EXAMPLEKEY, push.apns.topics = [com.example.chat], push.fcm.project_id = example-chat; the API runs in role api (no adapter, no push secret read). Every process of a test that runs in role worker or all (the jobs of M3-19, and the gateway tests in role all) gets the same configuration with push.apns.team_id, push.apns.key_id, push.apns.topics and push.fcm.project_id all empty, so internal/wire builds neither the APNs adapter of M3-20 nor the FCM adapter of M3-21 and reads no key file (their start check would otherwise stop the process for the missing push.apns.private_key_ref and push.fcm.service_account_ref files); the jobs of M3-19 run with fakepush.FakeProvider added to that empty provider set as apns and fcm, as M3-19's tests do, and the gateway tests need no push provider; job handlers are called directly after moving the fake clock. Device tokens are generated per run (apns: 64 lower-case hexadecimal digits from crypto/rand; fcm: a printable string with the marker EXAMPLE). Durations and limits are fixture values of the test configuration, never literals in assertions.
Unit:
internal/notify/domain:TestValidateRegistration_Table(one row per rule of Design note 4:wnsandunifiedpushgivenot_enabled;apnsfromandroidandfcmfromiosgiveinvalidat/provider;fcmwithsandboxgivesinvalidat/environment; anapnstoken withg, with an odd length and in upper case (accepted, lowered); anfcmtoken with a space givespattern_mismatch; token andapp_idone byte aboveFieldMaxBytesgivetoo_long; anapp_idoutside the topics givesinvalid; several faults give several entries);TestDisableReasonForSession(device_revokedanduser_disabledgivedevice_revoked;logout,admin,refresh_reuse,inactiveand an unknown value givelogout);TestParsePreviewMode(the three values, empty,FULL,yesgive the default);TestPreviewKindAllowed(six kinds true,security,admin,syncfalse).internal/notify/domain:TestExcerpt_Table(each step of Design note 10: mention@samof a mentioned principal becomes "Sam",@robinwithout mention row stays; fence, inline code, emphasis, strike, link, image, autolink, heading, quote, list and table markers; escapes; HTML literal; line breaks to one space; a bidi control removed; exactlymaxChars, one more, multi-byte characters at the cut);FuzzExcerpt(never panics; the result is valid UTF-8, has at mostmaxCharscode points and no control character);TestDomainPurityof M3-17 passes over the new files.internal/identity:TestAPIToken_KindPVW(the vector of kindpvwwith checksum4Z1L0Afromtestdata/checksum_vectors.json; generate, parse round trip; one changed checksum character fails);TestHashPushPreviewToken_MalformedWithoutDatabase(wrong prefix, kindpat, bad checksum, wrong length giveErrTokenMalformed; a counting fake repository sees no call);TestAuthenticate_PreviewTokenRejected(Bearer bothy_pvw_...andDPoP bothy_pvw_...on a normal operation give401 unauthenticatedwithout a database call);TestSessionActor_KeyThumbprint(equals the family'sdpop_jkt; unchanged flags of M3-05).internal/authz:TestPushRegisterRequirement(own device allowed; another person's devicenot_found; an API token principalforbiddenwithstep_up_not_possible; a pending devicedevice_pending);TestPushUnregisterVisibility(owner allowed; another personnot_found);TestMessageReadAndPushDeliverForRequest(reader allowed, non-readernot_found).api/routes:TestOperations_OwnCredentialFlag(set forgetPushPreviewonly);internal/platform/httpserver:TestStageAuthenticate_OwnCredentialSkipsAuthenticator(a counting fake Authenticator is not called for the flagged route, the actor is anonymous andactor.CredentialsFromreturns method,htu,Authorization,DPoPand client IP; for any other route it is called once).tools/ci/tests/test_gitleaks_rules.py: a generatedpvwtoken is detected bybothy-api-token.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, services/server/test/harness of M1-71):
TestRegisterPush_CreatesRowAndReturnsTokenOnce(13 6.4, 13 13): Alex's iPhone registersapnswithcom.example.chat:201withPushRegistrationCreated,environment = production, apreview_tokenof kindpvw; the row hasuser_id,device_id,auth_session_idof the session, the token in lower case,preview_token_hashequal to the HMAC of the returned token anddisabled_at IS NULL; the body never contains the device token; a replay with the sameIdempotency-Keyanswers409 conflictwithreasonone_time_secretandIdempotent-Replay: true, the row count is unchanged and the idempotency row holds no part of the preview token.TestRegisterPush_RotatesPreviewToken(13 6.4 "is rotated with every new registration"): a second registration of a new token on the same device and app gives a new preview token; the old row is disabled withtoken_invalid, its preview token gives401 unauthenticated, the new one works.TestRegisterPush_TakeoverOfKnownToken(13 13): Alex's tablet registersfcmtoken X; Sam's phone registers X: one row with X exists and belongs to Sam; Alex's preview token gives401 unauthenticated; a message of the Owner in a thread Alex follows gives nonotify.push_sendfor X addressed to Alex; a job queued for Alex's old row before the takeover ends withno_registration.TestRegisterPush_ConcurrentSameToken: two goroutines register the samefcmtoken from Alex's tablet and Sam's phone: both answer201or one409 conflict; exactly one row with that token exists and exactly one of the two preview tokens works.TestRegisterPush_Rules: through HTTP, one request per row ofTestValidateRegistration_Tablegives400 validation_failedwith the pointer and code; withpush.apns.team_id,push.apns.key_idandpush.apns.topicsall empty (APNs not enabled; a partial set fails the cross-checkpush_apns_completeof M3-20 at load) Alex'sapnsregistration gives403 feature_disabledwithreasonpush_provider_disabled; nothing is written in any case.TestRegisterPush_NeedsSessionAndActiveDevice: Alex's personal API token gives403 forbiddenwithreasonstep_up_not_possible; a pending device of Robin gives403 device_pending; an agent token gives403 forbidden; the deny audit entries exist.TestUnregisterPush: Alex removes his registration:204, the row is gone, its preview token gives401 unauthenticated; Sam on Alex's registration and anyone on a random UUIDv7 get identical404(status, headers, body after removingCorrelation-Id,Date,correlation_idand the id ininstance); a second removal gives404.TestPreview_PreviewModeNoneHasNoNames(PLAN row "preview_mode nonedelivers no names", 13 6.4 table): Alex writes in Support/Allgemein a Markdown message that mentions Sam and contains a canary word; Sam fetches it with her preview token andkind = mention. Withnotify.preview_mode = full(stored throughputMySettingof M1-61): all fields,excerptwith "Sam" as display name and no Markdown marker; withnames: the four name fields and noexcerpt,attachment_count,image_url; withnone: the member set of the JSON body is exactlykind,thread_id,message_id,badge, and the raw body contains neither the space, channel or thread name, nor Alex's display name, nor the canary word; with no stored value the defaultnotify.default_preview_modeapplies; a storedyescounts as unset.TestPreview_RevocationBetweenSendAndFetchIs404(PLAN row "a revocation between sending and fetching yields404", 13 14 row "Revocation between event and sending", 13 6.4): Sam follows thread T withall; Alex writes in T; the consumer and the job deliver one push to theFakeProviderfor Sam's phone with messagem; the Owner removes Sam from group Leser; Sam's fetch ofmanswers404, identical to the fetch of a random message id; the audit holds amessage.readdenial for Sam withno_grant;bothy.push.fetchforfcmrose by one.TestPreview_DeletedMessageIs404: the message deleted throughdeleteMessage(M1-58):404identical to an unknown id.TestLogout_PreviewTokenWorthlessAndNoPush(PLAN row, 13 14 row "auth.logout", 13 6.4): Alex logs out on his iPhone (logout): in the same transaction its registration getsdisabled_reason = 'logout'; its preview token gives401 unauthenticated; a new message in a thread Alex follows createsnotify.push_sendjobs only for the tablet's registration; a job for the iPhone queued before the logout ends withno_registrationand theFakeProviderrecords no call for it.TestDeviceRevoke_PreviewTokenWorthlessAndNoPush(PLAN row, ID-T26 step "push registrations", 13 7.3): Alex revokes his iPhone with step-up (revokeDevice): every registration of the iPhone hasdisabled_reason = 'device_revoked'in the revoking transaction; the preview token gives401 unauthenticated; no job is created for it afterwards; the tablet is unaffected.TestUserDisable_PreviewTokensWorthlessAndNoPush(PLAN row, ID-T22 step 5, 08 11): the Owner disables Alex: every registration of Alex is disabled withdevice_revokedin that transaction; both preview tokens give401 unauthenticated; nonotify.push_sendarises for Alex for a later message; a job queued before ends withno_registration.TestSessionEnd_ReasonMapping: a session ended by the Owner (revokeSession, reasonadmin) and one ended by refresh reuse givelogout; a test subscriber ofidentity.SessionEndedregistered afternotifythat vetoes once leaves the registration active; an expired session (fake clock pastauth_sessions.expires_at) leaves the row active and its preview fetch gives401 session_revoked.TestPreview_AuthenticationFailures: noAuthorization, two values, schemeBearer, a malformed token and a token with a wrong checksum give401 unauthenticated(the last two with no database query, counted through thepgteststatement log or a query counter); an unknown well-formed token gives401 unauthenticated; a proof signed with another device key, a proof withoutathor with theathof another token give401 invalid_dpop_proof; a proof without nonce gives401 use_dpop_noncewithDPoP-Nonce; a proof with the previous nonce gives200withDPoP-Nonce; Alex's access token on this endpoint gives401 unauthenticated; a registration whose session was revoked while the row stayed active (the test re-enables the row throughpgtestto reach the session check) gives401 session_revoked.TestPreviewToken_OnlyOnPreviewEndpoint(13 6.4 "It is valid only for this endpoint"): Sam's preview token with a valid proof onlistMyDevicesgives401 unauthenticated.TestPreview_KindParameter:kindmissing orsyncgives400 validation_failedat#query/kind(schema);securityandadmingive400 validation_failedwithinvalidat#query/kind; the answer echoes the six allowed kinds.TestPreview_Badge(13 8): thebadgeof the answer equalsBadgeCounter.Forfor Sam and theBadgethat M3-19 puts into theNotificationof a push to Sam at the same fake time (taken from theFakeProviderrecord).TestPreview_ImageURL(13 6.4, 12 9.2): a message with a text file and a PNG attached throughcreateMessage(M3-07), whosesmallthumbnail the jobfiles.thumbnail(M3-06) rendered, gives infullattachment_count = 2and animage_urlthat downloads with200throughdownloadThumbnailwithoutAuthorization; an image whose thumbnail is not rendered yet gives noimage_urland still200;namesandnonecarry neither field.TestGatewayHint_PreviewFieldsByMode(13 6.3, D-W-108 point 8; roleall,wstestof M1-64): Sam has a livemacosdesktop session and follows thread T in Support/Allgemein withall; Alex writes in T a Markdown message that mentions Sam, contains a canary word and has a PNG attached; runningnotify.gateway_senddeliversnotification.createdto Sam's connection. Withfull:space_name,channel_name,thread_title(where set),sender_display_name,excerptwith "Sam" as display name and no Markdown marker,attachment_count = 1, noimage_url; withnames: the name fields only; withnone(preview modenone): the payload member set is exactlynid,kind,thread_id,message_id,quiet,count, and the raw frame contains neither a space, channel or thread name, nor Alex's display name, nor the canary word. A hint of kindsecuritycarries no preview field.TestGatewayHint_LostAccessGetsNoNames(13 6.3, 13 14 row "Revocation between event and sending"): as above withfull; the Owner removes Sam from group Leser after the round and before the job runs: Sam's connection receives nonotification.created, and no frame, log record or span carries a name, the excerpt or the canary word; the variant of M3-23 in which the hub refuses the frame leaks none either; a message deleted between round and job gives an event without any preview field.TestDevice_PushRegistrationsMember:listMyDevicesandgetDevicelist the device's registrations (active and disabled withdisabled_atanddisabled_reason) without any token; a device without registration has no memberpush_registrations; another person's device stays404.TestNoTokensInLogsOrSpans(SEC-LOG-4, SEC-SEC-3, 13 7.3): with log capture and the in-memory span exporter of M1-10, a registration, a takeover, a successful and a failed fetch, a logout and an unregister leave the device token and the preview token (raw, base64 and hex) in no log record, span attribute, span event, problem body or audit row; the access log shows the route patterns.TestFetchMetric(signals.md 4.4): with an SDK manual reader,bothy.push.fetchperbothy.push.providercounts a200and a404fetch with a valid token and nothing for a malformed or unknown token.TestSEC_T10_CanaryAcrossAllOutputsandTestSEC_T10_ScenarioCoversEveryEnabledOperation(M1-69, PLAN row "the canary scenario contains a push token"): green with the scenario steps of Design note 15.TestAZ_P4_RouteCoverage(M1-70): green withunregisterPushandgetPushPreviewcovered.internal/wire: the golden registrations showidentity.SessionEnded -> notify.OnSessionEndedandidentity.DeviceRevoked -> notify.OnDeviceRevoked;TestConfigSchemaBuildslistspush.registration_field_max_bytes;make register-checkpasses with the edgenotify -> files.- Every response of this ticket passes the G8 validator of M1-71, and the three operations pass G7 in
make contract-test.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/notify/... ./internal/identity/... ./internal/authz/... ./internal/messaging/... ./internal/chat/... ./internal/kernel/... ./internal/platform/httpserver/... ./internal/wire/... ./api/... ./test/...make codegen-check,make arch,make register-check,make api-lint,make api-checks,make api-tables,make api-clients,make signal-check,make sql-visibility,make contract-testmake config-reference(no diff afterwards),make config-doc-check,make gitleaks,make diagrams,make docs,make linkcheckmake check
Acceptance criteria¶
-
registerPush,unregisterPushandgetPushPrevieware enabled, registered and pass AZ-P1;openapi.yamldeclarespushPreviewToken,PushRegistrationCreated, the parameterkind,409onregisterPushandDPoP-Nonceon the preview answer. - A registration binds user, device and session, returns a
pvwpreview token exactly once and stores only its HMAC; a replay answers409 conflictwithreasonone_time_secret; a known(provider, token)is taken over and one current token per app installation remains. - Provider and field rules answer
400 validation_failedwith their codes, a disabled provider403 feature_disabledwithreasonpush_provider_disabled, a principal without session403 forbiddenwithreasonstep_up_not_possible. - A revocation of rights between send and fetch gives
404, identical to an unknown or deleted message. - After
auth.logout,device.revokeoruser.disablethe registrations are deactivated in the same transaction (logoutordevice_revoked), the preview token gives401and no push reaches that device. -
notify.preview_mode = nonereturns onlykind,thread_id,message_idandbadge, without any name;namesandfullfollow the table of 13 6.4, withattachment_countandimage_urlbuilt from the attachments of M3-07 and the thumbnails of M3-06. -
notification.createdcarries the preview fields of 13 6.3 bynotify.preview_modefrom the same preview builder (withoutimage_url, Design note 9 f); withnoneit carries no name and no excerpt, and a reader who lost access gets no event and no name. - The preview token works only on
getPushPreviewand only with a DPoP proof of the device key; no device or preview token reaches a log, span, metric, audit row or problem body; the canary scenario contains a push token and a preview token (SEC-T10). -
Device.push_registrationslists the registrations of a device without tokens. - 13, 08, 02, ADR 0061, the C4 diagram, the threat model, signals.md,
configuration.md,.gitleaks.toml, the READMEs, the CHANGELOG and the rowsD-M3-22-1toD-M3-22-12are updated;make register-check,make signal-checkandmake contract-testare green. -
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.
- README of
notifyupdated in English (the write use cases onpush_registrations, the preview token and its check order, the preview modes and the excerpt, the preview fields ofnotification.created, the subscribers and the session reason mapping, the implementation ofidentity.PushTargets,bothy.push.fetch), keeping the lineMay use the ports of these modules: ...and the section## Tablesin their form for M1-06 (withfilesadded); READMEs ofidentity(kindpvw,Tokensadditions,SessionActorthumbprint, the sourcePushTargets),messaging,chat,authz,internal/platform/httpserver(OwnCredential) andapiname their additions. - Rulings of Design note 16 recorded in
docs/DECISIONS.md(one row each with reason); rulings 3, 4, 7, 8, 9 and 10 also indocs/spec/13-push-notifications.md. 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 (at least: the NSE and the Android service against this endpoint, M5-04 and M5-05; t2 minus t0 latency of 13 9;
image_urlinnotification.created, which Design note 9 f leaves out). - Follow-ups that could not be done in this scope, each with its reason, and spec gaps found (at least the takeover wording of 13 13).
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.