Phase 2 Architecture — Dependency Graph & Citations#

This page is the companion to phase2-architecture.md. The other page describes what the Stage 15 platform pieces (membership facade, job runner, search infrastructure) are. This page describes how every Phase 2 surface depends on them, with file-path citations so a new contributor can find the source quickly.

If you’re reading this for the first time, start with phase2-architecture.md.


1. The platform pieces (recap)#

ComponentPackageSchema
Membership facadebackend/internal/membership/schema/memberships/ (memberships_membership table)
Background job runnerbackend/internal/jobs/runner/schema/jobs_queue/ (jobs_queue_job table)
Search infrastructurebackend/internal/search/schema/search/ (search_document, recommendation_signal, recommendation_candidate)

All three components run unconditionally as of the Phase 2 GA cut; the Stage 15-era BITS_PHASE2_ENABLED gate has been removed.


2. Dependency graph#

                    +------------------+
                    | Stage 15         |
                    | Platform         |
                    | additions        |
                    +--------+---------+
                             |
           +-----------------+-----------------+-----------------------+
           |                 |                 |                       |
+----------v------+ +--------v---------+ +-----v-------+   +-----------v---------+
| Membership      | | Background       | | Search      |   | Phase 2 feature     |
| facade          | | job runner       | | infra       |   | flag                |
+----------+------+ +--------+---------+ +-----+-------+   +-----------+---------+
           |                 |                 |                       |
           |                 |                 |                       |
+----------v---------+-------v-----------------v-----------------------v---------+
|                                                                                |
|  Stage 16 Groups + Chapters     - membership IsMember on every read             |
|  Stage 17 Mentorship            - jobs/runner for matching; membership scope    |
|  Stage 18 Chapter events / CMS  - membership IsMember; reconciler upserts       |
|  Stage 19 Search & Recommender  - reads search_document; jobs/runner for batch  |
|  Stage 20 Marketplace           - jobs/runner for expiry; reconciler for index  |
|  Stage 21 Staff HR              - independent of platform pieces                |
|  Stage 22 AI / WhatsApp         - reads from every Phase 2 surface              |
|                                                                                 |
+--------------------------------------------------------------------------------+

Stage 21 (Staff HR) is the only Phase 2 surface that does not consume any of the Stage 15 pieces — it’s a self-contained payroll/leave subsystem. Every other surface depends on at least one.


3. Membership facade — who calls it#

backend/internal/membership/store.go:37 (IsMember) is the single hot-path helper.

CallerFilePurpose
Groups servicebackend/internal/groups/service.go:281 (loadMembershipOrZero) and call sites in Join/Leave/CreateGroupPostgates every group RPC; reads chapter membership for visibility
Mentorship servicevia Store and audience-scope checks in service.goenforces programme audience scoping
Calendar servicebackend/internal/calendar/service.go:180 (ListChapterEvents)gates chapter event read
Feed (Phase 1, migrated in Stage 16)backend/internal/feed/replaced ad-hoc batch checks with membership.IsMember
Recommenderbackend/internal/recommender/recommender.go:306 (loadMemberships)reads the user’s full membership set for content-based scoring
Marketplacebackend/internal/marketplace/service.go:800 (audienceTagsFor)converts membership rows into audience tag set

All chapter-scoped reads must go through membership.IsMember(ctx, userID, "group", chapterID) — the chapter’s group UUID is the scope ID.

Scope kinds in use#

backend/internal/membership/types.go:22:

  • batch — alumni/student batch year scope (Phase 1 carryover).
  • chapter — chapter group membership (Stage 16). Scope ID is the chapter Group.id.
  • group — interest group membership (Stage 16). Scope ID is the Group.id.
  • mentorship_programme — programme membership (Stage 17). Mentor and mentee profiles register here so the matcher can read them through the same facade.

Soft-delete semantics#

Membership rows are not deleted; RemoveMember flips state to left. BanMember flips to banned. AddMember is an explicit upsert that resets state to active — re-adding a banned user is intentional (backend/internal/membership/store.go:65).


4. Job runner — what runs on it#

backend/internal/jobs/runner/worker.go:155 (tick) claims a batch with FOR UPDATE SKIP LOCKED, then dispatches to handlers registered via Register(queueName, handler).

Handlers in flight:

QueueHandlerFile
mentorship_matchingmentorship.MatchingJobHandlerbackend/internal/mentorship/job_handler.go:32
marketplace_deal_expirymarketplace periodic expirybackend/internal/marketplace/jobs.go
(notifications delivery)Phase 1 notifications workerbackend/internal/notifications/

The recommender’s batch run uses a slightly different shape — it loops via Runner.Loop rather than per-job dispatch, but it shares the FOR UPDATE SKIP LOCKED polling pattern (backend/internal/recommender/recommender.go:93).

Idempotency contract#

Every handler must be idempotent — the runner is at-least-once. Concrete patterns used:

  • Mentorship: ON CONFLICT DO NOTHING on (programme_id, mentor_user_id, mentee_user_id) so re-running the matcher does not duplicate matches.
  • Marketplace expiry: re-running expiry on an already-expired deal is a no-op.
  • Recommender: candidate writes use a replace-set pattern keyed on (user_id, surface).

5. Search infrastructure — who writes, who reads#

Writers (via search.Reconciler)#

backend/internal/search/reconciler.go:

SurfaceSourceReconciler method
groupsbackend/internal/groups/service.go:1594 (reconcileGroup)ReconcileGroup (reconciler.go:57)
feed (group posts written to feed surface)backend/internal/groups/service.go:1614 (reconcileGroupPost)ReconcileGroupPost (reconciler.go:114)
eventsbackend/internal/calendar/ReconcileEvent (reconciler.go:222)
announcementsbackend/internal/cms/ReconcileAnnouncement (reconciler.go:164)
marketplacebackend/internal/marketplace/service.go:1036 (reconcileDeal)ReconcileDeal (under reconciler.go)
directorybackend/internal/directory/wired via Phase 1 directory reconciler
jobsbackend/internal/jobs/ (alumni job board)wired via Phase 1 jobs reconciler

Each writer commits the search row inside the same transaction as the row write — no async lag, the document is searchable the instant the mutating RPC commits.

Readers#

backend/internal/search/search.go:

  • Searcher.Search(query) → returns mixed-surface hits filtered by audience_tags && $N::text[] overlap with the caller’s tag set.
  • The audience tag set per caller is computed from role + memberships at query time (no precomputed user-tag column).

backend/internal/recommender/recommender.go:341 (scoreSurface) reads search_document rows for candidate generation, joining against recommendation_signal rows for collaborative-light signals.


6. Cross-cutting: audit + telemetry#

Every Phase 2 service writes audit rows via the same audit.DBWriter facade used in Phase 1. Pattern:

s.writeAudit(ctx, audit.Record{
    Action:     "bits.<domain>.v1.<Service>/<Method>",
    EntityType: "<Type>",
    EntityID:   id,
    After:      map[string]any{ ... },
})

This shape is consistent across backend/internal/groups/service.go:1527, backend/internal/mentorship/service.go:1310, backend/internal/marketplace/service.go:1027, and backend/internal/hr/service.go:464.

Structured telemetry (slog logger + tracing) is interceptor-level and applies to every gRPC handler regardless of stage; not specific to Stage 15.


7. Adding a new Phase 2 surface — checklist#

If you are adding a sixth Phase 2 service after Stage 23 GA:

  1. Membership — if your surface has scoped access, register a new ScopeType constant in backend/internal/membership/types.go and route reads through IsMember. Do not add a new join table.
  2. Jobs — if your surface has async work, register a queue name and a handler with Worker.Register. Make sure the handler is idempotent.
  3. Search — if your surface has searchable content, add a surface constant in backend/internal/search/types.go and a Reconciler method that the writer calls inside its transaction. Mirror the Django enum in schema/search/models.py.
  4. Audits.writeAudit(...) on every mutating RPC.
  5. Feature flag — gate behind a fresh per-stage flag (e.g. BITS_PHASE3_ENABLED) for new phase work; Phase 2 surfaces ship unconditionally.
  6. Capabilities — if admin-only, add a Capability enum in backend/internal/auth/capabilities.go.

The existing surfaces are all single-package examples of this pattern; the closest cross-section is backend/internal/groups/ (uses all four cross-cutting concerns).