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
What must be done — magic-indexer
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.
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
POST /notifications/graphql,lxm com.hypergoat.notification.query). certified-app reaches it through the same-origin BFF proxyPOST /api/notifications, which mints a per-request AT-Proto service-auth JWT from the user's OAuth/PDS session.endorsementandactivity-contributor. Notifications are server-side aggregated envelopes (one row per group with acount+latest*fields), not per-event objects.magic-indexer internal/notifications/types.go,resolver.go) and TS (certified-app src/lib/atproto/notifications.tsreason union +parseNotificationsPage, plus theOPERATIONSmap insrc/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.mdquality-029).badge-awardnotifications (app.certified.badge.award) are an agreed, deferred follow-up (seedocs/badge-response-flow/plan.md"Out of scope",review-round-1.mdD2). 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 ourapp.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.
reason+reasonSubject+knownValues; theseenAtwatermark read-model (we already do this); and — later — the shapesfilterablePreference {include, list, push}andregisterPush {serviceDid, token, platform, appId}.like/repost/follow/quoteare wrong for a certification product — ours areendorsement/activity-contributor/badge-award/ thefollowerEventsverbs), 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-awardlands, 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 heavyfeed_eventtable, 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 thefollowerEvents8-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
NotificationReasonunion and the hand-rolledparseNotificationsPage(src/lib/atproto/notifications.ts) with types generated from the shared lexicon artifact; resolve the unchecked-field parsing (quality-029) as part of that.OPERATIONSallowlist (src/app/api/notifications/route.ts), but source the operation shapes / field expectations from the shared contract rather than restating them.badge-awardas 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 onlatestRecordUri.filterablePreference {include, list, push}shape, wired to the serverreasons[]filter; later still, device-registration UI for push.What must be done — magic-indexer
com.hypergoat.notification.*/app.certified.notification.*) defining the query/procedure shapes (listNotifications/getUnreadCount/updateSeenanalogues), the reasonknownValuesenum, and the aggregated-envelope node shape. Use existinginternal/lexicon/tooling; expose it for certified-app codegen (e.g. via the existing npm/lexicon fetch path).badge-awardNotifier ininternal/notifications/extractors/keyed onapp.certified.badge.award, plus the reason constant ininternal/notifications/types.go— this is the agreed deferred follow-up that unblocks the certified-app work above.types.go/resolver.gostop being an independent source of truth./graphql(the "public-endpoint migration" already tracked in the RUNBOOK).reasons[]filter; aregisterPush-shaped device registration + push gateway; per-notification dismissal (dismissed_at) and email digests — all already in the RUNBOOK "Notifications follow-ups" backlog.Alternatives considered
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.*/ selectcom.atproto.*); a commit inapp.certified.*/org.hypercerts.*hits no handler and is dropped before any notification logic runs. Thereasonfield is a closed, server-defined enum with no extension hook, andregisterPushonly 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.)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.