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)#
| Component | Package | Schema |
|---|---|---|
| Membership facade | backend/internal/membership/ | schema/memberships/ (memberships_membership table) |
| Background job runner | backend/internal/jobs/runner/ | schema/jobs_queue/ (jobs_queue_job table) |
| Search infrastructure | backend/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.
| Caller | File | Purpose |
|---|---|---|
| Groups service | backend/internal/groups/service.go:281 (loadMembershipOrZero) and call sites in Join/Leave/CreateGroupPost | gates every group RPC; reads chapter membership for visibility |
| Mentorship service | via Store and audience-scope checks in service.go | enforces programme audience scoping |
| Calendar service | backend/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 |
| Recommender | backend/internal/recommender/recommender.go:306 (loadMemberships) | reads the user’s full membership set for content-based scoring |
| Marketplace | backend/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 chapterGroup.id.group— interest group membership (Stage 16). Scope ID is theGroup.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:
| Queue | Handler | File |
|---|---|---|
mentorship_matching | mentorship.MatchingJobHandler | backend/internal/mentorship/job_handler.go:32 |
marketplace_deal_expiry | marketplace periodic expiry | backend/internal/marketplace/jobs.go |
| (notifications delivery) | Phase 1 notifications worker | backend/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 NOTHINGon(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:
| Surface | Source | Reconciler method |
|---|---|---|
groups | backend/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) |
events | backend/internal/calendar/ | ReconcileEvent (reconciler.go:222) |
announcements | backend/internal/cms/ | ReconcileAnnouncement (reconciler.go:164) |
marketplace | backend/internal/marketplace/service.go:1036 (reconcileDeal) | ReconcileDeal (under reconciler.go) |
directory | backend/internal/directory/ | wired via Phase 1 directory reconciler |
jobs | backend/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 byaudience_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:
- Membership — if your surface has scoped access, register a new
ScopeTypeconstant inbackend/internal/membership/types.goand route reads throughIsMember. Do not add a new join table. - Jobs — if your surface has async work, register a queue name and a handler with
Worker.Register. Make sure the handler is idempotent. - Search — if your surface has searchable content, add a surface constant in
backend/internal/search/types.goand a Reconciler method that the writer calls inside its transaction. Mirror the Django enum inschema/search/models.py. - Audit —
s.writeAudit(...)on every mutating RPC. - Feature flag — gate behind a fresh per-stage flag (e.g.
BITS_PHASE3_ENABLED) for new phase work; Phase 2 surfaces ship unconditionally. - Capabilities — if admin-only, add a
Capabilityenum inbackend/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).