Skip to content

Notifications: publish a self-hosted, Bluesky-shaped notification lexicon (+ codegen), starting with badge-award #122

@hb-agent

Description

@hb-agent

Summary

Keep notifications self-hosted in magic-indexer (we have no alternative for our core events — see "Alternatives considered"), but stop hand-maintaining the contract in two places. When we add the next reason (badge-award), invest once in a single published notification lexicon artifact that both repos generate from, and shape that lexicon after Bluesky's mechanism (not its service, and not its reason values). This makes every future reason — and eventually preferences/push — cheap and consistent, and it's a clean hedge against the open hyperindex-v2 migration question (#43).

Background: where we are today

  • Notifications are produced inside magic-indexer's firehose ingestion (an extractor registry keyed on collection NSID) and served over a custom, service-auth-gated GraphQL endpoint (POST /notifications/graphql, lxm com.hypergoat.notification.query). certified-app reaches it through the same-origin BFF proxy POST /api/notifications, which mints a per-request AT-Proto service-auth JWT from the user's OAuth/PDS session.
  • There are exactly two reasons today: endorsement and activity-contributor. Notifications are server-side aggregated envelopes (one row per group with a count + latest* fields), not per-event objects.
  • The wire contract is duplicated by hand: Go (magic-indexer internal/notifications/types.go, resolver.go) and TS (certified-app src/lib/atproto/notifications.ts reason union + parseNotificationsPage, plus the OPERATIONS map in src/app/api/notifications/route.ts). There is no shared lexicon/schema artifact, so any new reason or field must be edited in both places by hand. The TS parser already trusts unchecked node fields (flagged as REVIEW.md quality-029).
  • badge-award notifications (app.certified.badge.award) are an agreed, deferred follow-up (see docs/badge-response-flow/plan.md "Out of scope", review-round-1.md D2). The backend still only detects the legacy temp endorsement lexicon, so today we mitigate client-side with a nav-badge counter of un-responded awards.

Suggestion (recommended path)

1. Generation stays self-hosted in magic-indexer. Permanently. This is a constraint, not a preference (see "Alternatives considered"). Notifications fire inside the ingestion hook keyed on collection NSID — that's the only place they can originate for our custom lexicons.

2. Publish one notification lexicon artifact and generate both sides from it. magic-indexer owns the source of truth (it already has lexicon tooling under internal/lexicon/ and ships lexicon JSON in our app.certified.* / org.hypercerts.* tree). certified-app generates its TS types from that artifact instead of hand-maintaining the reason union and a hand-rolled parser.

3. Mirror Bluesky's mechanism, not its service or its values.

  • Borrow: the closed reason enum with reason + reasonSubject + knownValues; the seenAt watermark read-model (we already do this); and — later — the shapes filterablePreference {include, list, push} and registerPush {serviceDid, token, platform, appId}.
  • Do not borrow Bluesky's reason values (like/repost/follow/quote are wrong for a certification product — ours are endorsement / activity-contributor / badge-award / the followerEvents verbs), and do not adopt their per-event shape. Keep our aggregation (count + latestAuthor → "Alice, Bob & 3 others"); the lexicon should encode the aggregated-envelope shape as our intentional, documented superset.

4. Timing — pull the trigger when badge-award lands, not before. With only two reasons and tiny scale (~14 badge awards network-wide), publishing the lexicon now would be premature (cf. the built-then-reverted heavy feed_event table, magic-indexer PR #123). The right moment is the third reason — badge-award — which is already queued. The likely future menu is already visible in the followerEvents 8-verb vocabulary (cert.create, endorsement.award, …).

5. Preferences/push are a later, separate phase. They're roadmapped (magic-indexer RUNBOOK "Notifications follow-ups") but unbuilt, and there is zero push plumbing (FCM/APNs/web-push/service-worker) in either repo. The lexicon gives us the wire format for free; it does not save implementation time. The server already accepts a reasons[] filter, so that's the natural server half once a preferences store exists.

What must be done — certified-app

  • Replace the hand-maintained NotificationReason union and the hand-rolled parseNotificationsPage (src/lib/atproto/notifications.ts) with types generated from the shared lexicon artifact; resolve the unchecked-field parsing (quality-029) as part of that.
  • Keep the BFF proxy and the OPERATIONS allowlist (src/app/api/notifications/route.ts), but source the operation shapes / field expectations from the shared contract rather than restating them.
  • Render badge-award as a first-class reason once the indexer emits it, and retire the client-side nav-badge mitigation (the un-responded-awards counter introduced in the badge-response flow). Today the row is special-cased via a /app.certified.badge.award/ substring on latestRecordUri.
  • (Later phase) Add a notification-preferences UI mirroring the filterablePreference {include, list, push} shape, wired to the server reasons[] filter; later still, device-registration UI for push.

What must be done — magic-indexer

  • Publish the notification lexicon artifact (our namespace, e.g. com.hypergoat.notification.* / app.certified.notification.*) defining the query/procedure shapes (listNotifications / getUnreadCount / updateSeen analogues), the reason knownValues enum, and the aggregated-envelope node shape. Use existing internal/lexicon/ tooling; expose it for certified-app codegen (e.g. via the existing npm/lexicon fetch path).
  • Add a badge-award Notifier in internal/notifications/extractors/ keyed on app.certified.badge.award, plus the reason constant in internal/notifications/types.go — this is the agreed deferred follow-up that unblocks the certified-app work above.
  • Codegen the Go types from the lexicon so types.go / resolver.go stop being an independent source of truth.
  • Keep the service-auth-gated transport until OAuth lands on public /graphql (the "public-endpoint migration" already tracked in the RUNBOOK).
  • (Later phase) Preferences store wired to the existing reasons[] filter; a registerPush-shaped device registration + push gateway; per-notification dismissal (dismissed_at) and email digests — all already in the RUNBOOK "Notifications follow-ups" backlog.

Alternatives considered

  • Use Bluesky's hosted notification system (app.bsky.notification.*). Not viable for our core events. Bluesky notifications are derived rows its AppView writes while indexing the firehose, only for collections it has hardcoded handlers for (app.bsky.* / chat.bsky.* / select com.atproto.*); a commit in app.certified.* / org.hypercerts.* hits no handler and is dropped before any notification logic runs. The reason field is a closed, server-defined enum with no extension hook, and registerPush only fans out AppView-generated notifications. It could at most be an additive source for Bluesky-native social events (follows, replies/quotes on a user's bsky posts) — never for endorsements/activities/badges. (Bluesky's own custom-schemas guidance: to use custom schemas you "will need to create and run an API service (App View)" of your own — which is what magic-indexer is.)
  • Keep the current bespoke contract unchanged (do nothing). Fine today, but the hand-duplicated Go/TS contract is a standing drift hazard that worsens with every added reason. Deferring the lexicon investment is reasonable only until the next reason (badge-award) lands — which is the trigger this issue proposes.

Relationship to the hyperindex-v2 migration (#43)

A host-agnostic lexicon artifact is the cheapest hedge against the open question of where notifications live: it's a contract file, so it survives whether the subsystem stays on the magic-indexer fork, folds into upstream hyperindex-v2, or is carved out as a companion service.


Design proposal only — no implementation in this issue. Cross-repo: the magic-indexer items above should land as a sibling issue on that repo.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions