Skip to content

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 with disabled_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 keys k, nid, t, m, badge; the sync form k = "sync"); 6.3 "Gateway event notification.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 on push.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, retention security); devices (platform, state); device_keys (purpose dpop); 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 rows push.register, push.unregister, push.deliver; 4.7 row message.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 only push.deliver without a request); 6 row notification.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 registrations disabled_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[].code vocabulary), 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 rows identity (DeviceRevoked, SessionEnded, declared source ports), notify (port PushRegistrations, subscriptions identity.DeviceRevoked and identity.SessionEnded), files; 4.4 "Allowed dependencies" (row notify, "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.md 9.2 "File tickets" (the image_url of the preview is such a ticket); 10 "Attaching and agent invocation" (message_attachments); 7.3 "Thumbnails" (the variant small).
  • 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: operations registerPush, unregisterPush, getPushPreview, listMyDevices and every other operation that returns Device; schemas PushRegistration, PushRegistrationCreate, PushProvider, PushEnvironment, PushPreview, NotificationKind, Device, Problem, FieldError; parameters IdempotencyKey, RegistrationIdPath; responses Unauthorized, Forbidden, NotFound, BadRequest; headers DPoP-Nonce, WWW-Authenticate; security schemes dpopAuth, 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, 404 instead of 403), 0020 (device binding, DPoP), 0048 (fetch tickets), 0053 (no JSON null), 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 rows D-M3-22-<n>).
  • docs/security/threat-model.md 5.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.md 4.4 row bothy.push.fetch, 5.3 attribute bothy.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 their Enabled rules), M3-22 (this ticket, the contract), M3-06 and M3-07 (thumbnails and attachments of the full preview), M3-23 (the job notify.gateway_send whose notification.created this 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, the files port Attachments), M3-06 (thumbnails, variant small), 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, port Tokens), 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 (ErrTokenMalformed and the kind hpc), 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, port PushRegistrations with Active, outgoing port RegistrationStore, the job notify.push_send and its skip reason no_registration, PushProvider, Notification with Badge, HasBadge and the sync form KindSync, BadgeCounter.For in internal/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 job notify.gateway_send with its args GatewaySend and steps 1 and 2 (Design note 4), NotificationCreatedPayload (Design note 7), Design note 9 and ruling D-M3-23-8, the live desktop tests with wstest); docs/briefs/M3-07-attachments-invocation.md (port files.Attachments with ForMessages, Attachment, File; every method inside the caller's unit of work, Design note 3); M3-06-image-metadata-thumbnails.md (the job files.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, the internal/wire/configkeys_notify.go file), M3-17-notify-decision.md (TestDomainPurity of internal/notify/domain), M1-45-identity-revocation.md (DeviceRevoked, SessionEnded, the order of user.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 (port Messages, 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.toml and tools/ci/tests/test_gitleaks_rules.py.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. 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).
  4. 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.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. 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.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/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:

  1. push.register registers a push target of the caller's own active device and returns the preview token exactly once; resource device, right self, no audit, rl.write (05 4.9, 13 13). The row binds user_id, device_id and auth_session_id of the caller's session (03 push_registrations).
  2. The preview token is issued once, stored only as HMAC in preview_token_hash, rotated with every new registration and valid only for getPushPreview (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).
  3. 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.
  4. push.unregister removes a registration of the caller; a foreign or unknown registration gives the same 404 (05 4.9, rule 2, B7).
  5. 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); then authorize(r, message.read, message) and push.deliver on its thread with current rights; no right, a deleted or an unknown message gives 404 (13 6.4, 05 4.9 row push.deliver, P3).
  6. The answer follows notify.preview_mode with the default notify.default_preview_mode: full with names, excerpt (plain text, mentions as names, at most notify.preview_max_chars), attachment count and image_url (a fetch ticket for small of the first image, 12 9.2); names without excerpt and attachments; none only kind, thread_id, message_id, badge (13 6.4 table).
  7. auth.logout deactivates the registrations of the session with disabled_reason = 'logout'; device.revoke deactivates all registrations of the device with device_revoked, at once; user.disable deactivates 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).
  8. 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.fetch per provider.
  9. notification.created carries the preview fields of 13 6.3 (space_name, channel_name, thread_title, sender_display_name, excerpt, attachment_count, image_url) by the recipient's notify.preview_mode, built by the same preview builder as the preview endpoint, except image_url, whose fetch ticket needs a session (Design note 9 f); none gives 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) and DisableReasonForSession(reason string) string (Design note 7); standard library and kernel-core only, so TestDomainPurity of 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 cases Register and Unregister), internal/notify/app/preview.go (use case Preview), internal/notify/app/previewbuilder.go (the preview builder shared by Preview and the job notify.gateway_send, Design note 9), internal/notify/app/deactivate.go (the subscribers OnSessionEnded and OnDeviceRevoked), internal/notify/app/pushtargets.go (the implementation of the source port identity.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 methods RegisterPush, UnregisterPush, GetPushPreview (Design note 12).

Change:

  • internal/notify port (root package): add to PushRegistrations of M3-19 Register(ctx context.Context, a actor.Actor, cmd RegisterCmd) (RegisteredPush, error), Unregister(ctx context.Context, a actor.Actor, registrationID ids.ID) error and Preview(ctx context.Context, req PreviewRequest) (PreviewView, error) with RegisterCmd{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} and PreviewView with the fields of the schema PushPreview. Doc comment on each.
  • internal/identity (additive): the kind pvw in domain/apitoken (Design note 3); the port Tokens gets NewPushPreviewToken(ctx context.Context) (PushPreviewToken, error) with PushPreviewToken{Secret <redacting type>; Hash []byte} and HashPushPreviewToken(ctx context.Context, raw string) ([]byte, error) returning ErrTokenMalformed; the Authenticator rejects the kind pvw without a database call on every operation; Auth.SessionActor of M3-05 also fills KeyThumbprint with the dpop_jkt of the session's family (Design note 8); the declared source port PushTargets and the field PushRegistrations of the Device DTO filled through it (Design note 11). testdata/checksum_vectors.json: one vector of kind pvw with the random part EXAMPLEpushpreviewtoken00000000000000000000 and the checksum 4Z1L0A (computed independently for this brief with the method that reproduces the hpc vector 4SigQv of M2-41; the random part carries the EXAMPLE marker).
  • internal/messaging port 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/chat port and adapter (additive): Structure.PreviewNames(ctx context.Context, threadID ids.ID) (PreviewNames, error) with PreviewNames{SpaceName, ChannelName, ThreadTitle string} (empty when the structure has none), unless Threads.Info or Structure already return these names.
  • internal/notify/app/gatewaysend.go (M3-23): step 1 of the job notify.gateway_send calls the preview builder and step 2 puts its fields into NotificationCreatedPayload (Design note 9 f).
  • internal/authz: the requirements of push.register and push.unregister, the visibility of the kind push_registration and the session rule (Design note 6); check the merged requirement of message.read and push.deliver against Design note 8.
  • internal/kernel/actor (additive, only if absent): WithCredentials(ctx context.Context, c Credentials) context.Context and CredentialsFrom(ctx context.Context) (Credentials, bool) (Design note 12).
  • api/routes (M1-17) and StageAuthenticate and StageValidate of internal/platform/httpserver (M1-18): the route flag OwnCredential for operations whose only security scheme is pushPreviewToken, and the stage behaviour of Design note 12; regenerate, make codegen-check.
  • api/operations.yaml: move registerPush, unregisterPush and getPushPreview from later to enabled; rerun the route generator.
  • internal/wire: the policy of Design note 4 from apns.Config, fcm.Config and push.apns.topics; the in-transaction subscriptions identity.SessionEnded -> notify.OnSessionEnded and identity.DeviceRevoked -> notify.OnDeviceRevoked; notify as the implementation of identity.PushTargets (replacing the empty source); the files ports Files and Attachments and the identity.Tokens, identity.DPoP, identity.Auth ports for notify; the three handlers; regenerate testdata/txn_registrations.golden.
  • architecture/modules.yaml: files in notify.depends_on; regenerate .go-arch-lint.yml (cd services/server && go generate ./tools/archgen).
  • internal/platform/otel/metrics: the Spec of bothy.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.mmd and its .svg (make diagrams), docs/security/threat-model.md, docs/observability/signals.md: Design note 14.
  • .gitleaks.toml rule bothy-api-token: the kind pvw in the regex and the keyword bothy_pvw_; tools/ci/tests/test_gitleaks_rules.py: a case that generates a pvw token (secrets.token_bytes(32), Base62, zlib.crc32) and asserts the rule fires.
  • test/canary, test/leak, tools/contract and the G8 validator (M1-69, M1-70, M1-71): Design note 15.
  • READMEs of notify, identity, messaging, chat, authz, internal/platform/httpserver and api; services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M3-22-1 to D-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 port PushRegistrations.Active, the outgoing port RegistrationStore, the job notify.push_send with 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 Enabled rules and push.apns.topics.
  • notification.created over the gateway, the job notify.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 of perf.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, the files port Attachments) 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_registrations for privacy requests and the retention class security: 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_registrations or one of its columns or indexes of 03, stop with Verdict: 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 of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (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 -d or t.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.FakeProvider of M3-19, tokens are generated per run (EXAMPLE marker 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 to disabled_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/files and internal/identity and list each difference.

Design notes

  1. Layers and dependencies (02 4.3, 4.4, 5; ADR 0008). The adapter internal/notify/adapters/httpapi maps request and response objects and holds no rule; the use cases Register, Unregister, Preview and the two subscribers live in internal/notify/app; validation, the session mapping, the preview mode and the excerpt are pure functions in internal/notify/domain. Every write of push_registrations stays in the notify postgres adapter; the outgoing port RegistrationStore and the job of M3-19 do not change. notify reaches identity (Tokens, DPoP, Auth.SessionActor, Devices, Directory), chat, messaging and sync through their ports, as 02 4.4 allows, and files (Files.IssueTicket of M3-05, Attachments.ForMessages of M3-07) through a new edge. Ruling for docs/DECISIONS.md: add files to "May use" of notify in 02 4.4 with the reason "attachments and fetch tickets for the preview" (English), to notify.depends_on in architecture/modules.yaml, the edge notify --> files to docs/architecture/c4-3-server-components.mmd and to the dependency line of the notify README; the edge points down (02 4.4 "Tiers"), and make register-check keeps the four equal. identity never imports notify: the registrations of a device reach the Device schema through the source port identity.PushTargets that identity declares and notify implements (Design note 11), the pattern of identity.AvatarFiles.

  2. Observability (signals.md 4.4, 13 9, 17; Ruling for docs/DECISIONS.md). Declare bothy.push.fetch as a Spec in the registry of M1-68 (counter, unit {notification}, attribute bothy.push.provider, role api) 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, or 401 from the session check), with the registration's provider. 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, the Authorization or DPoP header, a ticket URL, a name or a text (SEC-LOG-3, SEC-LOG-4, SEC-SEC-3); span attributes only bothy.push.provider; error texts are fixed strings.

  3. The preview token (13 6.4, ADR 0061, 08 12; Ruling for docs/DECISIONS.md). Form bothy_pvw_<random>_<checksum> from domain/apitoken of M1-44 with the new kind pvw: 256 bits from crypto/rand in Base62 as exactly 43 characters, CRC-32 (IEEE) over the random part as exactly 6 Base62 characters. Tokens.NewPushPreviewToken returns it in the redacting secret type of M1-44 with Hash = HMAC-SHA-256 with the pepper of auth.token_pepper_ref over the full token string, the derivation of api_tokens.token_hash. Tokens.HashPushPreviewToken checks form, kind pvw and checksum and returns ErrTokenMalformed before any HMAC for every other value (reuse ErrTokenMalformed if the merged code has it, otherwise create it with a doc comment). The Authenticator of M1-34 and M1-44 answers a pvw token under Bearer or DPoP on any operation with 401 unauthenticated without a database call, and createToken never derives the kind pvw. Reason for this form and not opaque bytes: the gitleaks rule bothy-api-token finds a leaked preview token (SEC-SEC-4), and the checksum rejects junk before the database (ADR 0061 point 2). The token is valid only through getPushPreview (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.

  4. 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/wire builds RegistrationPolicy{Enabled map[string]bool; APNsTopics []string; FieldMaxBytes int} from apns.Config.Enabled() (M3-20), fcm.Config.Enabled() (M3-21), push.apns.topics and push.registration_field_max_bytes, in every role, without building an adapter or reading a secret (the role api serves registerPush). After authorize (Design note 5 a), in this order:

    a. Provider unifiedpush or wns: 400 validation_failed with errors[].code not_enabled at /provider (13 5.4: no adapter in this server version).

    b. Provider apns or fcm that is not enabled on this instance: 403 feature_disabled with reason push_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's platform must be ios or macos for apns and android for fcm, otherwise invalid at /provider; environment absent means production, and sandbox with fcm is invalid at /environment (only APNs has two environments, 13 5.1); an apns token consists only of hexadecimal digits in an even number and is stored in lower case (the form M3-20 sends in the path), an fcm token only of printable ASCII without white space, otherwise pattern_mismatch at /token; token and app_id longer than push.registration_field_max_bytes are too_long; an apns app_id must be in push.apns.topics, otherwise invalid at /app_id (M3-20 would disable it as topic_mismatch at the first send).

    The new configuration key push.registration_field_max_bytes (integer, default 1024, minimum 64, maximum 2048, so a token always fits the B-tree entry of the unique key (provider, token)): in internal/wire/configkeys_push.go, in 13 17 and through make config-reference in the reference. Reason: the schema has no length for both fields, and an oversized token would fail the unique index with an internal error.

  5. 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; then identity.Devices.Get for the device's platform; 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). Pending notify.push_send jobs of the deleted row end with no_registration (M3-19).

    d. One current token per app installation: DisableOtherRegistrationsOfApp(device_id, provider, app_id) sets disabled_at = now and disabled_reason = 'token_invalid' on the other active rows of this device with the same provider and app_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: id UUIDv7, 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_count 0.

    f. idempotency.MarkOneTimeSecret(ctx) and 201 with PushRegistrationCreated (the registration and preview_token). A replay with the same Idempotency-Key answers 409 conflict with reason one_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 answers 409 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_registrations on their next read.

    i. unregisterPush: GetRegistrationForOwner(id) gives user_id; authorize(actor, push.unregister, push_registration {ID, OwnerID: user_id}); unknown and foreign give the same 404 not_found; then DeleteRegistration and 204. A second call answers 404. Removing frees the token for a later registration and ends its preview token.

  6. 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, kind device: visible and allowed only when OwnerID equals the principal (self; another person's device is not_found, 07 4.1 not_self); a principal without a session (agents, apps, API tokens) gets forbidden with reason step_up_not_possible, evaluated after visibility like the ticket rule of M3-05, because 03 binds every registration to auth_session_id and 07 4.1 defines that reason as "token without a session"; a device in state pending gets device_pending (05 2.5; push.register is not in the list of 07 4.1 for waiting devices).
    • push.unregister, kind push_registration (new resource kind in the descriptor, owner = user_id): visible only to its owner, otherwise not_found; requirement self.
    • message.read on kind message and push.deliver on kind thread for a request principal: read on the thread's path (05 2.2), not_found otherwise; if the merged entry of either is missing or deferred, add it here and remove it from the golden list of deferred actions.
  7. 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 in internal/wire after the subscribers that identity itself 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 with disabled_at IS NULL. Mapping onto the six values of 03: the session reasons device_revoked and user_disabled give device_revoked; every other session reason (logout, admin, refresh_reuse, inactive and any later one) gives logout, because for a registration "its session ended" is what logout stands 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 registrations device_revoked, immediately").

    c. user.disable publishes SessionEnded with user_disabled and one DeviceRevoked per 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.

  8. 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 Authorization value with the scheme DPoP, otherwise 401 unauthenticated with WWW-Authenticate: DPoP algs="ES256 EdDSA".

    b. Tokens.HashPushPreviewToken: ErrTokenMalformed gives 401 unauthenticated without a database call.

    c. GetRegistrationByPreviewHash with disabled_at IS NULL (the unique partial index of 03): no row gives 401 unauthenticated with WWW-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 adds KeyThumbprint = the dpop_jkt of 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 whose DeviceID differs from reg.DeviceID gives 401 unauthenticated.

    e. identity.DPoP.Verify(ProofRequest{Proofs, Method, URL, AccessToken: raw preview token, ExpectedJKT: actor.KeyThumbprint}) with URL the htu the chain computed: a DPoPError gives 401 with its code (invalid_dpop_proof or use_dpop_nonce) and the DPoP-Nonce header (errs.WithHeader); NonceStale adds DPoP-Nonce with CurrentNonce() to the answer. ath binds 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_at set, or kind IN ('run','system') gives 404 not_found without authorize (Ruling: a deleted message answers like an unknown one, 13 6.4). Then authorize(actor, message.read, message) and authorize(actor, push.deliver, thread of the message) in normal mode, so denials are audited with the person as actor (07 4.2). A decision unauthenticated (principal_inactive, session_revoked, device_inactive) gives 401 session_revoked, as the chain maps it for every request (M1-34); every other denial gives 404 not_found, identical to an unknown message (status, headers and body except correlation_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").

  9. Preview answer (13 6.4 table, 13 8, 12 9.2; ADR 0053; Ruling for docs/DECISIONS.md):

    a. kind comes from the new required query parameter kind (NotificationKind), which the app copies from the payload (b.k, data.k, 13 6.1, 6.2); domain.PreviewKindAllowed accepts only the kinds that carry a message (message, mention, dm, run_done, run_failed, input_needed), security and admin give 400 validation_failed with errors[].code invalid at #query/kind. Reason: the server keeps no record of a hint (jobs only, R-PN-09), so neither nid nor the message tells which kind the push had; the kind only selects labels on the device, never content, and the answer is decided by message.read alone. The sync form of 13 8 (k = "sync", the iOS push with only badge) carries no message and never reaches this endpoint: the NSE does not run without alert, and sync is not a NotificationKind.

    b. Always: kind, thread_id (the message's thread), message_id, and badge = the badge number of 13 8 for the person at the time of the fetch, computed by BadgeCounter.For(ctx, userID) of M3-19 (internal/notify/app/badge.go, M3-19 Design note 16), the same function that fills Badge and HasBadge of the provider port's Notification (aps.badge, data.badge).

    c. The mode is domain.ParsePreviewMode of the stored notify.preview_mode (sync.Settings.ValuesFor) with the default notify.default_preview_mode; a stored value that is not full, names or none counts as unset. names and full add space_name, channel_name, thread_title (from chat; absent when empty, for example a DM channel or a main thread without title) and sender_display_name (identity.Directory.Principals of the author, also for agents and apps). full adds excerpt (Design note 10; absent when empty, for example a voice message), attachment_count and image_url from files.Attachments.ForMessages of M3-07 (called only when has_attachments is true, inside the read-only unit of work of the use case, because every Attachments method runs in the caller's unit of work): attachment_count is the number of attachments (absent when zero), image_url the url of Files.IssueTicket(actor, file, small) of M3-05 for the first attachment in position order whose mime_type starts with image/, whose small thumbnail M3-06 renders; any error of IssueTicket (not ready, blocked, no small thumbnail yet) leaves image_url out and never fails the preview.

    d. For run_done the excerpt is the one of the last agent message and for input_needed the one of the question, because the push of M3-19 points m at 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 adds DPoP-Nonce when 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.go holds the mode, the names, the excerpt and the attachments of c and d behind one builder that Preview and the job notify.gateway_send of M3-23 both call; only the mapping into PushPreview or NotificationCreatedPayload differs.

    • In step 1 of notify.gateway_send (its unit of work), after its push.deliver check (or the R-AZ-30 rule) passed, and only for a kind that domain.PreviewKindAllowed accepts with a set MessageID: read the recipient's notify.preview_mode (sync.Settings.ValuesFor, domain.ParsePreviewMode with notify.default_preview_mode); none builds nothing. Otherwise messaging.Messages.ForPreview(MessageID): unknown, deleted_at set, run or system builds nothing, and the event still goes out with its IDs as M3-23 decides. Then the fields of c for names or full.
    • Rights: no message.read here. The job evaluates the recipient as a human without a request (R-AZ-29), and 05 section 5 lists only push.deliver and the R-AZ-30 rule for hints. The message lies in the thread that step 1 has just checked with push.deliver and the rights of that moment, both resolve to read on the thread's path (Design note 6), and the hub checks push.deliver again 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_url stays absent in notification.created, in every mode: a fetch ticket is bound to a session (12 9.2; M3-05 Design note 7: step_up_not_possible without 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_count is filled for full.
    • 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); ruling D-M3-22-9 records that it supersedes D-M3-23-8.
  10. Excerpt (13 6.4 "text without Markdown, mentions as names, at most notify.preview_max_chars"; Ruling for docs/DECISIONS.md). domain.Excerpt is 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 @handle whose handle (compared like lower(handle) of 03) belongs to a principal in the message's mentions rows becomes that principal's display name; any other @ text stays.

    b. Markdown of content_format 1: 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_chars Unicode code points is cut to that number minus one code point followed by U+2026; no code point is ever split.

  11. Registrations in Device (openapi Device.push_registrations; M1-33 left the member out "(M3)"; 02 4.3; Ruling for docs/DECISIONS.md). identity declares the source port PushTargets with ForDevices(ctx context.Context, deviceIDs []ids.ID) (map[ids.ID][]PushTargetView, error) and PushTargetView{ID, DeviceID ids.ID; Provider, Environment, AppID string; CreatedAt, DisabledAt time.Time; DisabledReason string} (no token, no hash); internal/wire wires an empty source until notify implements it with ListRegistrationsOfDevices (one query per response, ascending id, disabled rows included). The Device mapper of identity fills push_registrations in every Device it returns (listMyDevices, listUserDevices, getDevice, approveDevice, revokeDevice and every other operation with that schema) and leaves the member out when the device has none (ADR 0053); disabled_at and disabled_reason only for disabled rows. The read runs after identity authorized the device, so it calls no authorize.

  12. 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. getPushPreview gets the new security scheme pushPreviewToken instead of dpopAuth. The route generator sets routes.Operation.OwnCredential exactly for operations whose only security scheme is pushPreviewToken, with a test.

    b. StageAuthenticate does not call the Authenticator for such a route: it sets the anonymous actor and stores the actor.Credentials it already builds (method, htu, the values of Authorization and DPoP, client IP) in the context with actor.WithCredentials. Every other route is unchanged, so a preview token on any other operation gets 401 unauthenticated from the Authenticator (Design note 3).

    c. StageRateLimit therefore sees an anonymous request and takes the rl.read token from the bucket of the client IP (M1-19). A failed preview credential is not counted against rl.public_auth: the token has 256 random bits and a checksum, as the tickets of M3-05.

    d. StageValidate must accept a request that carries the preview token; add a test if the validator checks security requirements.

    e. RegisterPush maps PushRegistrationCreate to RegisterCmd and RegisteredPush to PushRegistrationCreated and marks the one-time secret; UnregisterPush answers 204; GetPushPreview reads actor.CredentialsFrom(ctx) and calls Preview. Every use case calls authorize, so the proof of the check of 02 9.3 holds.

  13. 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; only getPushPreview accepts it (13 6.4, R-PN-07).
    • registerPush: 201 with the new schema PushRegistrationCreated (allOf of PushRegistration and an object with the required preview_token, described as shown exactly once and never again); add 409 (Conflict: replay of the one-time secret, concurrent takeover); the description names the takeover, the one current token per app installation, 403 feature_disabled with reason push_provider_disabled, 403 forbidden with reason step_up_not_possible and 403 device_pending. PushRegistrationCreate: descriptions of token and app_id name push.registration_field_max_bytes and the per-provider rules of Design note 4.
    • unregisterPush: description (removes the row; foreign and unknown give the same 404).
    • getPushPreview: security pushPreviewToken; the required query parameter kind (NotificationKind, description of Design note 9 a); DPoP-Nonce on 200; the description lists the order of Design note 8 and which failures give 401 and which 404.
    • Device.push_registrations: description (all registrations of the device, without tokens).
    • Run make api-lint, make api-checks, make api-tables, make codegen-check and make api-clients.
  14. 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 form pvw, the check order of Design note 8, 401 for token, session, device and person, 404 for rights and messages, the parameter kind, 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 with push_provider_disabled, unregisterPush deletes; 17, the row push.registration_field_max_bytes.
    • docs/spec/08-identity.md 7.1: a row "Preview token (push)" (form bothy_pvw_..., bound to the registration, its session and the device key, lifetime of the registration, stored as HMAC-SHA-256).
    • docs/spec/02-architecture.md 4.3: row identity "declares PushTargets", row notify "implements identity.PushTargets"; 4.4 row notify (Design note 1).
    • docs/adr/0061-token-format-with-a-fixed-prefix-and-checksum.md point 1: a supplement sentence with the kind pvw (preview token of a push registration, 13 6.4), like the supplement for hpc.
    • docs/architecture/c4-3-server-components.mmd: the edge notify --> files; make diagrams.
    • docs/security/threat-model.md 5.12: rows S | 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.md 4.4 row bothy.push.fetch: what counts (Design note 2).
    • docs/operations/configuration.md through make config-reference.
  15. 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 class secret (provider fcm, so the hexadecimal rule of apns does not apply), registers the issued preview token as a canary of class secret (allowed only in the registerPush response that issues it and in the getPushPreview requests that carry it), fetches a preview with it, reads listMyDevices, unregisters, and sends Authorization: DPoP bothy_pvw_<canary> as an invalid value. TestSEC_T10_ScenarioCoversEveryEnabledOperation and TestSEC_T10_CanaryAcrossAllOutputs pass.
    • Leak (test/leak, AZ-P4): TestAZ_P4_RouteCoverage covers unregisterPush (a registration of Alex for Sam against a random id) and getPushPreview (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 scheme pushPreviewToken (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.
  16. Rulings for docs/DECISIONS.md (section 10, one row each with date, decision, reason and place): D-M3-22-1 the edge notify -> files and the source port identity.PushTargets (notes 1, 11); D-M3-22-2 the token form pvw, its HMAC and its lifetime (note 3); D-M3-22-3 provider and field rules, push_provider_disabled, push.registration_field_max_bytes (note 4); D-M3-22-4 takeover by deletion and one current token per app installation (note 5 c, d); D-M3-22-5 one-time secret replay and the concurrent takeover (note 5 f, g); D-M3-22-6 the session rule of push.register and the resource kind push_registration (note 6); D-M3-22-7 deactivation by in-transaction subscribers and the session reason mapping, expiry without deactivation (note 7 a to c); D-M3-22-8 the preview check order, 401 versus 404, the session actor with KeyThumbprint (note 8); D-M3-22-9 the parameter kind, the fields per mode and the preview fields of notification.created, superseding D-M3-23-8 (note 9); D-M3-22-10 the excerpt rules (note 10); D-M3-22-11 the route flag OwnCredential, rate limiting by client IP (note 12); D-M3-22-12 what bothy.push.fetch counts (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: wns and unifiedpush give not_enabled; apns from android and fcm from ios give invalid at /provider; fcm with sandbox gives invalid at /environment; an apns token with g, with an odd length and in upper case (accepted, lowered); an fcm token with a space gives pattern_mismatch; token and app_id one byte above FieldMaxBytes give too_long; an app_id outside the topics gives invalid; several faults give several entries); TestDisableReasonForSession (device_revoked and user_disabled give device_revoked; logout, admin, refresh_reuse, inactive and an unknown value give logout); TestParsePreviewMode (the three values, empty, FULL, yes give the default); TestPreviewKindAllowed (six kinds true, security, admin, sync false).
  • internal/notify/domain: TestExcerpt_Table (each step of Design note 10: mention @sam of a mentioned principal becomes "Sam", @robin without 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; exactly maxChars, one more, multi-byte characters at the cut); FuzzExcerpt (never panics; the result is valid UTF-8, has at most maxChars code points and no control character); TestDomainPurity of M3-17 passes over the new files.
  • internal/identity: TestAPIToken_KindPVW (the vector of kind pvw with checksum 4Z1L0A from testdata/checksum_vectors.json; generate, parse round trip; one changed checksum character fails); TestHashPushPreviewToken_MalformedWithoutDatabase (wrong prefix, kind pat, bad checksum, wrong length give ErrTokenMalformed; a counting fake repository sees no call); TestAuthenticate_PreviewTokenRejected (Bearer bothy_pvw_... and DPoP bothy_pvw_... on a normal operation give 401 unauthenticated without a database call); TestSessionActor_KeyThumbprint (equals the family's dpop_jkt; unchanged flags of M3-05).
  • internal/authz: TestPushRegisterRequirement (own device allowed; another person's device not_found; an API token principal forbidden with step_up_not_possible; a pending device device_pending); TestPushUnregisterVisibility (owner allowed; another person not_found); TestMessageReadAndPushDeliverForRequest (reader allowed, non-reader not_found).
  • api/routes: TestOperations_OwnCredentialFlag (set for getPushPreview only); internal/platform/httpserver: TestStageAuthenticate_OwnCredentialSkipsAuthenticator (a counting fake Authenticator is not called for the flagged route, the actor is anonymous and actor.CredentialsFrom returns method, htu, Authorization, DPoP and client IP; for any other route it is called once).
  • tools/ci/tests/test_gitleaks_rules.py: a generated pvw token is detected by bothy-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 registers apns with com.example.chat: 201 with PushRegistrationCreated, environment = production, a preview_token of kind pvw; the row has user_id, device_id, auth_session_id of the session, the token in lower case, preview_token_hash equal to the HMAC of the returned token and disabled_at IS NULL; the body never contains the device token; a replay with the same Idempotency-Key answers 409 conflict with reason one_time_secret and Idempotent-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 with token_invalid, its preview token gives 401 unauthenticated, the new one works.
  • TestRegisterPush_TakeoverOfKnownToken (13 13): Alex's tablet registers fcm token X; Sam's phone registers X: one row with X exists and belongs to Sam; Alex's preview token gives 401 unauthenticated; a message of the Owner in a thread Alex follows gives no notify.push_send for X addressed to Alex; a job queued for Alex's old row before the takeover ends with no_registration.
  • TestRegisterPush_ConcurrentSameToken: two goroutines register the same fcm token from Alex's tablet and Sam's phone: both answer 201 or one 409 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 of TestValidateRegistration_Table gives 400 validation_failed with the pointer and code; with push.apns.team_id, push.apns.key_id and push.apns.topics all empty (APNs not enabled; a partial set fails the cross-check push_apns_complete of M3-20 at load) Alex's apns registration gives 403 feature_disabled with reason push_provider_disabled; nothing is written in any case.
  • TestRegisterPush_NeedsSessionAndActiveDevice: Alex's personal API token gives 403 forbidden with reason step_up_not_possible; a pending device of Robin gives 403 device_pending; an agent token gives 403 forbidden; the deny audit entries exist.
  • TestUnregisterPush: Alex removes his registration: 204, the row is gone, its preview token gives 401 unauthenticated; Sam on Alex's registration and anyone on a random UUIDv7 get identical 404 (status, headers, body after removing Correlation-Id, Date, correlation_id and the id in instance); a second removal gives 404.
  • TestPreview_PreviewModeNoneHasNoNames (PLAN row "preview_mode none delivers 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 and kind = mention. With notify.preview_mode = full (stored through putMySetting of M1-61): all fields, excerpt with "Sam" as display name and no Markdown marker; with names: the four name fields and no excerpt, attachment_count, image_url; with none: the member set of the JSON body is exactly kind, 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 default notify.default_preview_mode applies; a stored yes counts as unset.
  • TestPreview_RevocationBetweenSendAndFetchIs404 (PLAN row "a revocation between sending and fetching yields 404", 13 14 row "Revocation between event and sending", 13 6.4): Sam follows thread T with all; Alex writes in T; the consumer and the job deliver one push to the FakeProvider for Sam's phone with message m; the Owner removes Sam from group Leser; Sam's fetch of m answers 404, identical to the fetch of a random message id; the audit holds a message.read denial for Sam with no_grant; bothy.push.fetch for fcm rose by one.
  • TestPreview_DeletedMessageIs404: the message deleted through deleteMessage (M1-58): 404 identical 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 gets disabled_reason = 'logout'; its preview token gives 401 unauthenticated; a new message in a thread Alex follows creates notify.push_send jobs only for the tablet's registration; a job for the iPhone queued before the logout ends with no_registration and the FakeProvider records 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 has disabled_reason = 'device_revoked' in the revoking transaction; the preview token gives 401 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 with device_revoked in that transaction; both preview tokens give 401 unauthenticated; no notify.push_send arises for Alex for a later message; a job queued before ends with no_registration.
  • TestSessionEnd_ReasonMapping: a session ended by the Owner (revokeSession, reason admin) and one ended by refresh reuse give logout; a test subscriber of identity.SessionEnded registered after notify that vetoes once leaves the registration active; an expired session (fake clock past auth_sessions.expires_at) leaves the row active and its preview fetch gives 401 session_revoked.
  • TestPreview_AuthenticationFailures: no Authorization, two values, scheme Bearer, a malformed token and a token with a wrong checksum give 401 unauthenticated (the last two with no database query, counted through the pgtest statement log or a query counter); an unknown well-formed token gives 401 unauthenticated; a proof signed with another device key, a proof without ath or with the ath of another token give 401 invalid_dpop_proof; a proof without nonce gives 401 use_dpop_nonce with DPoP-Nonce; a proof with the previous nonce gives 200 with DPoP-Nonce; Alex's access token on this endpoint gives 401 unauthenticated; a registration whose session was revoked while the row stayed active (the test re-enables the row through pgtest to reach the session check) gives 401 session_revoked.
  • TestPreviewToken_OnlyOnPreviewEndpoint (13 6.4 "It is valid only for this endpoint"): Sam's preview token with a valid proof on listMyDevices gives 401 unauthenticated.
  • TestPreview_KindParameter: kind missing or sync gives 400 validation_failed at #query/kind (schema); security and admin give 400 validation_failed with invalid at #query/kind; the answer echoes the six allowed kinds.
  • TestPreview_Badge (13 8): the badge of the answer equals BadgeCounter.For for Sam and the Badge that M3-19 puts into the Notification of a push to Sam at the same fake time (taken from the FakeProvider record).
  • TestPreview_ImageURL (13 6.4, 12 9.2): a message with a text file and a PNG attached through createMessage (M3-07), whose small thumbnail the job files.thumbnail (M3-06) rendered, gives in full attachment_count = 2 and an image_url that downloads with 200 through downloadThumbnail without Authorization; an image whose thumbnail is not rendered yet gives no image_url and still 200; names and none carry neither field.
  • TestGatewayHint_PreviewFieldsByMode (13 6.3, D-W-108 point 8; role all, wstest of M1-64): Sam has a live macos desktop session and follows thread T in Support/Allgemein with all; Alex writes in T a Markdown message that mentions Sam, contains a canary word and has a PNG attached; running notify.gateway_send delivers notification.created to Sam's connection. With full: space_name, channel_name, thread_title (where set), sender_display_name, excerpt with "Sam" as display name and no Markdown marker, attachment_count = 1, no image_url; with names: the name fields only; with none (preview mode none): the payload member set is exactly nid, 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 kind security carries no preview field.
  • TestGatewayHint_LostAccessGetsNoNames (13 6.3, 13 14 row "Revocation between event and sending"): as above with full; the Owner removes Sam from group Leser after the round and before the job runs: Sam's connection receives no notification.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: listMyDevices and getDevice list the device's registrations (active and disabled with disabled_at and disabled_reason) without any token; a device without registration has no member push_registrations; another person's device stays 404.
  • 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.fetch per bothy.push.provider counts a 200 and a 404 fetch with a valid token and nothing for a malformed or unknown token.
  • TestSEC_T10_CanaryAcrossAllOutputs and TestSEC_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 with unregisterPush and getPushPreview covered.
  • internal/wire: the golden registrations show identity.SessionEnded -> notify.OnSessionEnded and identity.DeviceRevoked -> notify.OnDeviceRevoked; TestConfigSchemaBuilds lists push.registration_field_max_bytes; make register-check passes with the edge notify -> 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-test
  • make config-reference (no diff afterwards), make config-doc-check, make gitleaks, make diagrams, make docs, make linkcheck
  • make check

Acceptance criteria

  • registerPush, unregisterPush and getPushPreview are enabled, registered and pass AZ-P1; openapi.yaml declares pushPreviewToken, PushRegistrationCreated, the parameter kind, 409 on registerPush and DPoP-Nonce on the preview answer.
  • A registration binds user, device and session, returns a pvw preview token exactly once and stores only its HMAC; a replay answers 409 conflict with reason one_time_secret; a known (provider, token) is taken over and one current token per app installation remains.
  • Provider and field rules answer 400 validation_failed with their codes, a disabled provider 403 feature_disabled with reason push_provider_disabled, a principal without session 403 forbidden with reason step_up_not_possible.
  • A revocation of rights between send and fetch gives 404, identical to an unknown or deleted message.
  • After auth.logout, device.revoke or user.disable the registrations are deactivated in the same transaction (logout or device_revoked), the preview token gives 401 and no push reaches that device.
  • notify.preview_mode = none returns only kind, thread_id, message_id and badge, without any name; names and full follow the table of 13 6.4, with attachment_count and image_url built from the attachments of M3-07 and the thumbnails of M3-06.
  • notification.created carries the preview fields of 13 6.3 by notify.preview_mode from the same preview builder (without image_url, Design note 9 f); with none it carries no name and no excerpt, and a reader who lost access gets no event and no name.
  • The preview token works only on getPushPreview and 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_registrations lists 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 rows D-M3-22-1 to D-M3-22-12 are updated; make register-check, make signal-check and make contract-test are green.
  • make check is 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 notify updated in English (the write use cases on push_registrations, the preview token and its check order, the preview modes and the excerpt, the preview fields of notification.created, the subscribers and the session reason mapping, the implementation of identity.PushTargets, bothy.push.fetch), keeping the line May use the ports of these modules: ... and the section ## Tables in their form for M1-06 (with files added); READMEs of identity (kind pvw, Tokens additions, SessionActor thumbprint, the source PushTargets), messaging, chat, authz, internal/platform/httpserver (OwnCredential) and api name 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 in docs/spec/13-push-notifications.md.
  • services/server/CHANGELOG.md entry 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 check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. 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.
  3. Review and red team verdicts and the findings fixed.
  4. 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_url in notification.created, which Design note 9 f leaves out).
  5. 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>.