Skip to content

Context Map

How Objectuve's bounded contexts relate to each other. This document shows the integration patterns, data flow direction, and coupling points between contexts.

High-Level Context Map

graph TB
    subgraph External
        Clerk["Clerk (Auth Provider)"]
        Stripe["Stripe (Payments)"]
        AR["Agent Runner<br/>(Cloud Run + LiteLLM)"]
        Strava["Strava / Chess.com<br/>(Activity Providers)"]
    end

    subgraph Objectuve
        GT["Goal Tracking<br/>(Core Domain)"]
        SC["Social & Community<br/>(Accountability)"]
        UI["User Identity<br/>(Shared Kernel)"]
        GP["Gamification<br/>(Supporting)"]
        AI["AI Coaching<br/>(Supporting)"]
        GA["Guide Assistant<br/>(sub-context of AI Coaching)"]
        AW["AI Workforce<br/>(Platform Infra)"]
        BL["Billing<br/>(Supporting)"]
        TM["Teams<br/>(sub-context of Billing)"]
        FB["Feedback<br/>(Supporting)"]
        IN["Integrations & Connected Apps<br/>(Supporting)"]
        PA["Personal Analytics<br/>(Supporting)"]
        IE["Import/Export<br/>(Supporting)"]
        CM["Content Moderation<br/>(Cross-Cutting)"]
        AD["Admin<br/>(Cross-Cutting)"]
        GD["GDPR & Data Rights<br/>(Cross-Cutting)"]
    end

    Clerk -- "ACL: ClerkJwtVerifier<br/>ClerkUserSync" --> UI
    Stripe -- "Webhook (unmediated today;<br/>proposed Billing::StripeAdapter)" --> BL
    AR -- "HMAC Webhook<br/>ProcessWebhookResult" --> AW
    AW -- "HTTP (OIDC)<br/>Ai::AgentRunnerClient" --> AR
    Strava -- "ACL: IntegrationProvider::Adapter<br/>(OAuth webhook / poll)" --> IN
    IN -- "AutoCheckIn -> GoalEvent<br/>(write)" --> GT
    PA -. "Read-only: Goal, GoalEvent,<br/>HabitCompletion" .-> GT
    PA -. "Read-only: badge-XP proxy" .-> GP
    PA -. "Read-only: streak, mood logs" .-> UI
    IE -. "Read-only: goal names<br/>(collision detection)" .-> GT

    UI -. "Shared Kernel:<br/>every context reads User" .-> GT
    UI -. "Shared Kernel" .-> SC
    UI -. "Shared Kernel" .-> GP
    UI -. "Shared Kernel" .-> AI
    UI -. "Shared Kernel" .-> AW
    UI -. "Shared Kernel" .-> BL
    UI -. "Shared Kernel" .-> FB

    GT -- "Events trigger<br/>badge jobs" --> GP
    GT -- "Events trigger<br/>feed generation" --> SC
    SC -- "Community join<br/>triggers badge" --> GP
    UI -- "Sign-in triggers<br/>streak XP" --> GP

    GT -. "Goal data<br/>(read-only)" .-> AI
    AI -. "Generates milestones,<br/>insights" .-> GT
    UI -. "EnneagramAssessment<br/>(conformist read)" .-> AI
    AI -- "Owns Guide Assistant<br/>(stateless, no shared state)" --> GA

    SC -- "Reads goals for<br/>feed & sharing" --> GT

    BL -- "Owns billing, seats,<br/>sub-communities" --> TM
    GT -- "Job enqueue: leaderboard +<br/>collective goal upsert" --> TM

    CM -. "Screens content from" .-> SC
    CM -. "Screens content from" .-> GT

    GD -. "Deletion/anonymization<br/>cascades" .-> GT
    GD -. "Deletion/anonymization<br/>cascades" .-> SC
    GD -. "Deletion/anonymization<br/>cascades" .-> GP
    GD -. "Deletion/anonymization<br/>cascades" .-> FB
    GD -. "Reads for export<br/>preview" .-> GT
    GD -. "Reads for export<br/>preview" .-> SC
    GD -. "Reads for export<br/>preview" .-> BL
    GD -. "Reads for export<br/>preview" .-> UI

    AD -. "Audits actions across" .-> GT
    AD -. "Audits actions across" .-> SC
    AD -. "Audits actions across" .-> FB
    AD -. "Audits actions across" .-> AW
    AD -. "Reviews artifacts" .-> AW
    AD -. "Updates post status" .-> FB

Integration Patterns

Shared Kernel: User Identity

Every context needs the User reference. User Identity is the shared kernel — a small set of models and interfaces that multiple contexts depend on.

What's SharedHow It's Used
User model (id, public_id, name)Foreign key on Goal, CommunityMember, UserAlly, MoodLog, etc.
context[:current_user]GraphQL resolvers pass the authenticated user to every interaction
User.public_find(id)Standard lookup pattern across all contexts

Rule: Other contexts may read User fields (name, public_id, email) but must not write to User directly. Profile mutations go through User Identity interactions (UpdateUser, UpdateUserPhoto).

Exception: User writes originating outside User Identity split into mediated (routed through a domain event User Identity subscribes to) and unmediated (a direct write from the other context's own code). scripts/check-shared-kernel-writes.mjs enforces the unmediated side — it greps rails_api/app/ and rails_api/lib/ for direct User write call sites and fails the build on any that aren't in its allowlist. This table is the allowlist's doc-side mirror; keep the two in sync when either changes.

WriterContextMediated?Status
GamificationService.award_xpUser.xp, User.levelGamification❌ direct (user.save! inside user.with_lock, not update-family — outside the guard's regex, but still an unmediated write in practice)Sanctioned exception
Gamification::AwardStimXpUser.stim_xp_total, stim_streak_current, stim_streak_longest, stim_last_played_onGamification✅ event-mediated via critical_path.stim_completed (see 04 — Domain Events)Sanctioned exception — see Resolved: Critical Path → Gamification (event-driven) below
Billing::SubscribersUser.supporter_tier, supporter_untilBilling✅ event-mediated via billing.payment_processed (see 04 — Domain Events, 04-domain-events.md:44-58)Sanctioned exception
Billing::ExpireSupportersJobUser.supporter_tier, supporter_untilBilling❌ direct, scheduled job (expire_supporters_job.rb:12)Sanctioned exception
AdminRole#sync_legacy_admin_flagUser.adminAdmin❌ direct, after_save callback (admin_role.rb:17)Sanctioned exception
Gdpr::UserDataCascade#scrub_user_row! → PII columnsGDPR❌ direct (user.update_columns, user_data_cascade.rb:280)Sanctioned exception

Resolved: StripeService.find_or_create_customer used to write user.stripe_customer_id directly (stripe_service.rb:17, pre-OBJ-2366). It now calls UserIdentity::AssignStripeCustomerId (rails_api/app/interactions/user_identity/assign_stripe_customer_id.rb) — a User Identity interaction, so the write is mediated by construction and carries no allowlist entry.

Anti-Corruption Layer: Clerk

Clerk is an external authentication provider. We isolate it behind two services:

sequenceDiagram
    participant Client as Frontend
    participant GQL as GraphQL
    participant ACL as ClerkJwtVerifier
    participant Clerk as Clerk JWKS
    participant Sync as ClerkUserSync
    participant DB as User table

    Client->>GQL: Request + SessionToken header
    GQL->>ACL: Verify JWT
    ACL->>Clerk: Fetch JWKS (cached)
    Clerk-->>ACL: Public keys
    ACL-->>GQL: Decoded claims (clerk_user_id)
    GQL->>DB: User.find_by(clerk_user_id:)
    GQL->>GQL: Set context[:current_user]

    Note over Client,DB: On first sign-in only:
    Client->>GQL: syncUser mutation
    GQL->>Sync: ClerkUserSync.call
    Sync->>DB: Find or create User
  • ClerkJwtVerifier decodes RS256 JWTs via Clerk's JWKS endpoint. It never exposes Clerk internals to the rest of the system.
  • ClerkUserSync maps Clerk profile data to a local User record. Runs once per session on first sign-in.

Upstream/Downstream: Goal Tracking → Gamification

Goal Tracking is upstream — it produces domain events that Gamification subscribes to. The coupling is now event-driven (loosely coupled):

sequenceDiagram
    participant CI as CheckInHabit
    participant DE as DomainEvents
    participant GS_sub as Gamification::Subscribers
    participant GS as GamificationService

    Note over CI: Goal Tracking context
    CI->>CI: Create HabitCompletion
    CI->>CI: Update goal streak
    CI->>DE: publish('goal_tracking.habit_checked_in', {...})
    Note over DE: Event Facade
    DE->>GS_sub: Emit event (async-safe)
    Note over GS_sub: Gamification context
    GS_sub->>GS: award_xp(user, 25, 'Habit Check-in')
    GS_sub->>GS: AwardPartnerBonus.call(user:, completed_date:)
    GS->>GS: user.xp += 25
    GS->>GS: check_for_level_up

Current state (v1.12 Phase 53): CheckInHabit publishes the goal_tracking.habit_checked_in domain event after persisting the habit completion. Gamification::Subscribers subscribes to this event and handles XP award and partner bonus independently. Goal Tracking no longer knows about Gamification's internals.

Legacy state: AddGoal still directly triggers badge detection jobs. This remains a candidate for future migration to goal.created event (see Phase 54).

Upstream/Downstream: Goal Tracking → Social & Community

When users log progress, the Social & Community context needs to propagate that to followers:

sequenceDiagram
    participant AGE as AddGoalEvent / CheckInHabit
    participant Job as GenerateFollowFeedItemsJob

    Note over AGE: Goal Tracking context
    AGE->>AGE: Create GoalEvent
    AGE->>Job: Enqueue (async)
    Note over Job: Social & Community context
    Job->>Job: Find goal followers
    Job->>Job: Create UserFeedItem per follower

Current state: This is already loosely coupled via an async background job. The interaction doesn't know how feed items are created — it just fires the job. This is a good pattern.

Upstream/Downstream: Goal Tracking → Teams

Goal Tracking is upstream of Teams (a sub-context of Billing — see 01 — Bounded Contexts). Two Goal Tracking interactions enqueue the same pair of Teams jobs so a team's leaderboard and collective-goal progress stay current whenever a member logs progress:

sequenceDiagram
    participant CI as CheckInHabit /<br/>AddGoalEvent
    participant LB as Teams::TeamLeaderboardUpsertJob
    participant CG as Teams::CollectiveGoalContributionUpsertJob

    Note over CI: Goal Tracking context
    CI->>CI: Persist HabitCompletion / GoalEvent
    CI->>LB: Enqueue (async)
    CI->>CG: Enqueue (async)
    Note over LB,CG: Teams context (sub-context of Billing)
    LB->>LB: Upsert TeamLeaderboardEntry for (team, community, user, period)
    CG->>CG: Upsert CollectiveGoalContribution.events_contributed

Current state: Both GoalTracking::CheckInHabit and GoalTracking::AddGoalEvent enqueue Teams::TeamLeaderboardUpsertJob and Teams::CollectiveGoalContributionUpsertJob (4 enqueue call sites, 2 job classes) after persisting their own record. This is the same loosely coupled async job shape as Goal Tracking → Social & Community above — the interaction doesn't know how leaderboard/contribution rows are computed, it just fires the jobs. Previously undocumented; see item 7 in 05 — Application Services for the coupling-heatmap entry.

Upstream/Downstream: Social & Community → Gamification

JoinCommunity → JoinFirstCommunityActionJob → UserAction.join_first_community

Same pattern as Goal Tracking → Gamification: direct job trigger from the interaction. Should become a domain event.

Resolved: Critical Path → Gamification (event-driven)

Critical Path used to be tightly coupled to User Identity — CriticalPath::AwardStimXp wrote directly to four progression fields on User without routing through GamificationService:

CriticalPath::RecordPlay
  → CriticalPath::AwardStimXp
    → user.increment!(:stim_xp_total, delta)
    → user.update!(stim_streak_current:, stim_streak_longest:, stim_last_played_on:)

Decided 2026-08-09 (Option C, docs/architecture/proposals/critical-path-user-coupling.md): Option B (event-driven, no direct User write) is the target state; Option A's direct-write shape was recorded as a transitional exception until the migration shipped. Migrated per OBJ-2370CriticalPath::RecordPlay now publishes critical_path.stim_completed (see 04 — Domain Events); Gamification::Subscribers consumes it and calls the moved Gamification::AwardStimXp interaction, which performs the same row-locked write, now from inside the context that's already the sanctioned User-writing exception:

sequenceDiagram
    participant RP as CriticalPath::RecordPlay
    participant DE as DomainEvents
    participant GS_sub as Gamification::Subscribers
    participant AS as Gamification::AwardStimXp

    Note over RP: Critical Path context
    RP->>RP: Find or create CriticalPathPlay
    RP->>DE: publish('critical_path.stim_completed', {...})
    Note over DE: Event Facade (synchronous, in-process)
    DE->>GS_sub: Emit event
    Note over GS_sub: Gamification context
    GS_sub->>AS: AwardStimXp.call(user:, critical_path_play:)
    AS->>AS: user.lock! + increment!/update! stim fields

The subscriber runs synchronously, not via perform_laterPlayView.vue does a read-after-write refetch of stim-XP status immediately after the mutation resolves, which only works if the award has already landed by the time RecordPlay returns. scripts/check-shared-kernel-writes.mjs's allowlist entry now points at rails_api/app/interactions/gamification/award_stim_xp.rb as a permanent entry, alongside gamification_service.rb's exception; the old transitional critical_path/award_stim_xp.rb entry is gone because that file no longer exists.

Phase 2 coupling-point: Entry 6 in the Modularization Guide migration order — now marked migrated.

Secondary coupling: CriticalPath::SetActiveTheme and CriticalPath::SetReminderPreferences write to UserDetail — this follows the same loose belongs_to :user pattern used by Billing and Feedback and is low-risk.

Cross-Cutting: GDPR & Data Rights

GDPR & Data Rights is a cross-cutting concern that reads from all bounded contexts to fulfill data-subject rights (export, deletion, rectification). See 01 — Bounded Contexts for the full reconciled section — there is no GdprExport model; export state lives on GdprRequest itself (export_file_url, export_expires_at).

sequenceDiagram
    participant User
    participant Submit as Gdpr::SubmitRequest
    participant Ack as Gdpr::SendAcknowledgmentJob
    participant Export as Gdpr::ExecuteExport /<br/>RequestSelfServiceExport
    participant Build as Gdpr::BuildExportBundleJob
    participant Builder as Gdpr::ExportBuilder
    participant Contexts as All Bounded Contexts
    participant Mail as GdprMailer

    User->>Submit: Submit export/deletion/rectification request
    Submit->>Submit: Create GdprRequest(status: 'received')
    Submit->>Ack: enqueue
    Ack->>Mail: acknowledgment(request) — starts 72h SLA clock

    Export->>Export: GdprRequest status → 'processing'
    Export->>Build: enqueue(request_id, actor_id)
    Build->>Builder: build export bundle
    Builder->>Contexts: Read user data (all contexts)
    Contexts-->>Builder: Goals, events, posts, badges, etc.
    Build->>Build: Upload ZIP to private GCS bucket
    Build->>Build: GdprRequest status → 'fulfilled', export_file_url set
    Build->>Mail: export_ready(request, download_url, expires_at)
  • Cascade Preview: Gdpr::CascadePreview is read-only and shows the admin exactly what a deletion will touch (per-model counts).
  • Execution: the actual erasure cascade lives in Gdpr::UserDataCascade (app/services/gdpr/user_data_cascade.rb), shared by two callers — Gdpr::ExecuteDeletion (admin-triggered, step-up-gated) and Gdpr::DeleteSelfServiceAccount (self-service, OBJ-2183) — each wrapping the call in its own transaction and handling its own admin-flow bookkeeping (step-up token, GdprRequest finalization, AdminAction audit row). The cascade is a mixed hard/soft-delete, not a uniform soft-delete — goals, mood logs, badges, devices, community memberships, and AI memories are hard-deleted (really_destroy!/delete_all); community posts/comments and the User row itself are soft-deleted after PII scrubbing. See 01 — Bounded Contexts for the full breakdown. Gdpr::ExecuteExport (admin) and Gdpr::RequestSelfServiceExport (self-service) both delegate the actual build/upload to Gdpr::BuildExportBundleJob.
  • Reads: GDPR reads from Goal Tracking, Social, Gamification, Billing, Feedback, User Identity, and AI. Write dependencies are limited to its own GdprRequest aggregate (no separate export aggregate exists).

Conformist: AI Coaching → Goal Tracking

AI Coaching reads Goal Tracking data as-is. It has no influence over the Goal model structure — it conforms to whatever Goal Tracking provides. This is appropriate for a supporting domain.

Anti-Corruption Layer + Upstream/Downstream: Integrations & Connected Apps → Goal Tracking

Integrations & Connected Apps (see 01 — Bounded Contexts) is both an ACL and a write-upstream of Goal Tracking:

sequenceDiagram
    participant Provider as Strava / Chess.com
    participant Adapter as IntegrationProvider::Adapter
    participant Map as MapIncomingActivity
    participant ACI as AutoCheckIn
    participant CI as GoalTracking::CheckInHabit

    Note over Provider,Adapter: External provider format
    Provider->>Adapter: Webhook / poll response
    Adapter->>Adapter: Normalize to IntegrationActivity (activity_type, timestamp)
    Note over Map: Integrations context
    Adapter->>Map: score against candidate goals
    Map-->>ACI: decision: auto (score >= 0.85)
    Note over ACI: Integrations context
    ACI->>CI: CheckInHabit.call(goal_id:, completed_date:)
    Note over CI: Goal Tracking context
    CI->>CI: Create HabitCompletion, update streak
    ACI->>ACI: Create attribution GoalEvent (source: :integration)

Current state: IntegrationProvider::Adapter (and its Strava/Chess implementations) is the anti-corruption layer isolating each provider's own vocabulary from Objectuve's activity_type taxonomy — the same shape as the Clerk ACL for auth. Once inside the boundary, Integrations::AutoCheckIn is the sole write path into Goal Tracking: it calls GoalTracking::CheckInHabit directly and creates its own attribution GoalEvent, rather than publishing a domain event Goal Tracking subscribes to. This is the inverse direction of every other Goal-Tracking-is-upstream pattern in this document — here, Integrations is upstream and Goal Tracking is the target of a direct interaction call.

Conformist (Multi-Context): Personal Analytics → Goal Tracking, Gamification, User Identity

Personal Analytics (see 01 — Bounded Contexts) owns no aggregates of its own — every series it returns is a read-only projection over three other contexts:

Reads FromWhatInteraction
Goal TrackingGoal, GoalEvent, HabitCompletionCalculateCompletionByCategory, CalculateActivityHeatmap, CalculateMonthInReview
GamificationBadge-unlock XP proxy (PersonalAnalytics::BadgeXpProxy, no per-event XP ledger exists)CalculateXpOverTime, CalculateMonthInReview
User IdentityUser#streak_activity_dates, #streak_length_on, #current_streak, MoodLogCalculateStreakHistory, CalculateActivityHeatmap, CalculateMonthInReview

This is the same conformist relationship AI Coaching has with Goal Tracking, fanned out across three upstream contexts instead of one — Personal Analytics has no influence over any of their models and adapts entirely to what they provide. Nothing in Goal Tracking, Gamification, or User Identity is aware Personal Analytics reads it; there is no coupling in the reverse direction.

Conformist (Read-Only): Import/Export → Goal Tracking

Import/Export (see 01 — Bounded Contexts) reads Goal Tracking once per parse, for collision detection only — it never writes:

Reads FromWhatInteraction
Goal Trackingcurrent_user.goals names (memoized Set, one query per parse)ImportExport::ParseCsv

As of Phase 2 (v4.64), this context stages a validated preview and stops — a staged DataImport is never itself written into goals/habit_completions; that write path is Phase 3+ and does not exist yet. ImportExport::CsvSchema (Phase 1) is also the read-side contract Gdpr::ExportBuilder writes against on export, making the CSV column shape a published language shared by GDPR & Data Rights and this context, not something either side owns independently.

Published Language: GraphQL Schema

The GraphQL schema is the published language between the frontend and backend. It defines the API contract that both sides agree on.

  • Queries and mutations are organized by domain concern (goals, communities, users, gamification, AI)
  • Types mirror aggregate structure (GoalType has events, milestones, etc.)
  • The frontend's graphql.js is a 1:1 mapping of this contract

Coupling Summary

FromToMechanismCoupling LevelAction
Goal TrackingGamificationEvent-driven via goal_tracking.habit_checked_inLoose✅ Implemented in v1.12 Phase 53 (commit 6b61cffc)
Goal TrackingGamificationInline badge job triggersTightReplace with domain event
Goal TrackingSocialAsync GenerateFollowFeedItemsJobLooseKeep as-is
SocialGamificationInline JoinFirstCommunityActionJobTightReplace with domain event
SocialGoal TrackingToggleGoalEventReaction reads goal eventLoose (read-only)Keep as-is
SocialGoal TrackingToggleGoalEventEncouragement reads goal eventLoose (read-only)Keep as-is
SocialGoal TrackingBuildUnifiedFeed reads goals/eventsLoose (read-only)Keep as-is
SocialGoal TrackingAddGoalToCommunity reads goalLoose (read-only)Keep as-is
User IdentityGamificationDirect GamificationService.award_xp in update_streak!TightReplace with domain event
Content ModerationSocialAddPostComment calls ContentModerationService.screenLoose (inline service call)Keep as-is
Content ModerationGoal TrackingScreens GoalEventComment contentLoose (inline service call)Keep as-is
AI CoachingGoal TrackingReads goal dataLoose (conformist)Keep as-is
User Identity (Weekly Digest)GamificationWeeklyDigest::AssembleDigestData reads StimXpLedger for weekly XPLoose (conformist read-only)Keep as-is — digest assembly conforms to Gamification's ledger shape
User Identity (Weekly Digest)Goal TrackingWeeklyDigest::AssembleDigestData reads user.goals for CTA URLLoose (conformist read-only)Keep as-is
User Identity (Weekly Digest)SocialWeeklyDigest::AssembleDigestData reads ally list + GoalEvent activity for recent-allies sectionLoose (conformist read-only)Keep as-is
User Identity (Weekly Digest)AI CoachingWeeklyDigest::AssembleDigestData calls Ai::ServiceClient (batch/insights model) for personalized insightLoose (service call, read-only)Keep as-is — same AI seam used by all coach surfaces
AllUser Identitycontext[:current_user]Shared KernelAccept as infrastructure
User IdentityClerkClerkJwtVerifier, ClerkUserSyncACLKeep isolated
AI WorkforceUser IdentityReads User for operator-triggered runs, role lookupsLoose (read-only)Keep as-is
AI WorkforceGoal TrackingPotential future read access (employees summarizing user progress)None today (loose if added)Keep read-only
AI WorkforceAgent Runner (external)Ai::AgentRunnerClient HTTP + HMAC webhookWebhook-drivenWrap with AiWorkforce::AgentRunnerAdapter ACL
AI WorkforceAI Coaching (shared infra)Shared AiUsageEvent write pathLoose (append-only)Keep as-is
BillingUser IdentityBilling::Subscribers writes User.supporter_tier/supporter_until (event-mediated via billing.payment_processed); Billing::ExpireSupportersJob writes the same fields directly (scheduled job)Sanctioned shared-kernel exception (both writes land on User, not on Billing's own PaymentRecord/Plan)Keep as-is — see Shared Kernel exception table
BillingStripe (external)StripeService + Webhooks::StripeControllerWebhook-drivenStripe ACL implemented in v1.5 (Billing::ProcessStripeWebhook); Billing::StripeAdapter extraction still proposed
BillingRevenueCat (external)Webhooks::RevenueCatControllerBilling::ProcessRevenueCatWebhookWebhook-drivenMirrors Stripe pattern; same billing.payment_processed event + same Billing::Subscribers handler. Verified: no new cross-context reads (reads only User Shared Kernel + Billing's own Plan/PaymentRecord).
AI CoachingUser Identity (Enneagram)Reads EnneagramAssessment for persona personalizationLoose (conformist read-only)Keep as-is — AI Coaching conforms to User Identity's assessment data shape
FeedbackUser IdentityFeedbackPost/Comment/Vote belongs_to :userLoose (read-only on User)Keep as-is
Critical PathUser IdentityGamification::AwardStimXp writes stim_xp_total, stim_streak_current, stim_streak_longest, stim_last_played_on, triggered by critical_path.stim_completed (published from CriticalPath::RecordPlay)Loose (event-driven)Decided 2026-08-09 (Option C, docs/architecture/proposals/critical-path-user-coupling.md); migrated per OBJ-2370 — see Shared Kernel exception table
Critical PathUser IdentitySetActiveTheme writes UserDetail.critical_path_active_theme; SetReminderPreferences writes UserDetail + conditionally User.timezoneLooseKeep as-is
Goal TrackingTeams (sub-context of Billing)CheckInHabit and AddGoalEvent each enqueue Teams::TeamLeaderboardUpsertJob + Teams::CollectiveGoalContributionUpsertJobLoose (async job enqueue)Keep as-is — newly documented (previously untracked); see 05 — Application Services
AdminAllAudit via AdminAction polymorphic target; RBAC via AdminRoleCross-cuttingAccept as infrastructure
AdminAI WorkforceApprove/reject artifacts, update employeesDirect interaction callsKeep as-is
AdminFeedbackUpdate post status, mark official commentsDirect interaction callsKeep as-is
Integrations & Connected AppsGoal TrackingIntegrations::AutoCheckIn calls GoalTracking::CheckInHabit directly, writes attribution GoalEventTight (direct interaction call, not event-driven)Candidate for a goal_tracking.check_in_requested style event if a second upstream writer emerges; low priority today — single caller
Integrations & Connected AppsStrava / Chess.com (external)IntegrationProvider::Adapter (OAuth webhook for Strava, polled REST for Chess.com)ACLKeep isolated — same pattern as the Clerk ACL
Personal AnalyticsGoal TrackingReads Goal, GoalEvent, HabitCompletion for heatmap/category/month-in-review seriesLoose (conformist read-only)Keep as-is
Personal AnalyticsGamificationReads badge-unlock XP proxy (PersonalAnalytics::BadgeXpProxy)Loose (conformist read-only)Keep as-is
Personal AnalyticsUser IdentityReads streak_activity_dates, streak_length_on, current_streak, MoodLogLoose (conformist read-only)Keep as-is
Import/ExportGoal TrackingImportExport::ParseCsv reads current_user.goals names for collision detectionLoose (conformist read-only)Keep as-is

Dependency Direction

                    ┌──────────────┐
                    │  Clerk (ext) │
                    └──────┬───────┘
                           │ ACL
                    ┌──────▼───────┐
              ┌─────│User Identity │─────┐
              │     │(Shared Kernel)│     │
              │     └──────────────┘     │
              │             │            │
              ▼             ▼            ▼
    ┌─────────────┐  ┌───────────┐  ┌──────────┐
    │Goal Tracking│  │ Social &  │  │   AI     │
    │   (Core)    │  │ Community │  │ Coaching │
    └──┬───┬──────┘  └──┬──┬─────┘  └──────────┘
       │   │             │  │
       │   │  ┌──────────┘  │
       │   ▼  ▼             │
       │ ┌─────────────────┐│
       │ │  Gamification    ││
       │ │  (Downstream)    ││
       │ └─────────────────┘│
       │                    │
       └──┐            ┌────┘
          ▼            ▼
    ┌─────────────────────┐
    │  Content Moderation  │
    │  (Cross-Cutting)     │
    └─────────────────────┘

Dependencies flow downward. Gamification is the most downstream context — it reacts to events from Goal Tracking, Social & Community, and User Identity. No other context depends on Gamification's internals.

AI Workforce, Billing, Feedback, and Integrations & Connected Apps sit as sibling supporting contexts alongside Goal Tracking / Social, each with their own external seams:

  • AI Workforce ↔ Agent Runner: bidirectional HTTP + HMAC webhook (already mediated by Ai::AgentRunnerClient + Webhooks::AiWorkforceController; proposed AgentRunnerAdapter would tighten the ACL further).
  • Billing ↔ Stripe: unidirectional inbound webhook today handled in the controller (unmediated). Candidate for the Billing::StripeAdapter ACL that mirrors the Clerk pattern — this is the single biggest boundary gap in the current context map.
  • Feedback: no external coupling. The lightest-weight of the supporting contexts.
  • Integrations & Connected Apps ↔ Strava / Chess.com: bidirectional — inbound OAuth webhook for Strava (real-time), outbound polled REST for Chess.com — both already mediated by IntegrationProvider::Adapter, the same ACL shape Clerk uses. Unlike the other sibling contexts, it also writes into a domain sibling (Goal Tracking) rather than only outward to an external provider — see Anti-Corruption Layer + Upstream/Downstream: Integrations & Connected Apps → Goal Tracking above.

Teams (sub-context of Billing) has no external seam of its own — billing/checkout flows through Billing's Stripe seam via Teams::StartTeamCheckout / Teams::ProvisionTeamSubscription. Its one cross-context dependency is inbound, from a sibling internal context rather than an external one: Goal Tracking → Teams via the two job enqueues documented in Upstream/Downstream: Goal Tracking → Teams above.

Guide Assistant (sub-context of AI Coaching) has no cross-context edges at all, in either direction — it reads only the committed guide corpus (rails_api/config/ai/guide_corpus.json), never live domain data from Goal Tracking, User Identity, or any other context. See 01 — Bounded Contexts.

Personal Analytics has no external seam and no sibling-context dependency of its own to document here — it is purely a downstream reader of Goal Tracking, Gamification, and User Identity (see Conformist (Multi-Context): Personal Analytics → Goal Tracking, Gamification, User Identity above), the same shape as AI Coaching's relationship to Goal Tracking.

Architectural Decision Records

ADR: Models as Publishers (Phase 54 Decision)

Decision: Models never publish domain events directly. Only interactions and background jobs may publish. When a model's state change produces a side effect in another context, the publishing responsibility moves to the interaction that called the model method or to a subscriber job that reacts to the model change.

Rationale: The interaction layer is the single write boundary across all contexts. Publishing from interactions ensures a clear, auditable flow: interaction → model mutation → event emission → subscribers. Publishing from models (or from model callbacks) obscures the transaction boundary and prevents interactions from controlling the emission order or payload. Phase 53's CheckInHabit interaction publishing goal_tracking.habit_checked_in proved this pattern is cheap and correct; endorsing bounded exceptions would undermine it.

Implication: Goal#update_habit_streak! was refactored in Phase 54.1b to return the streak delta instead of publishing gamification.streak_advanced directly. The publishing responsibility moved to the two callers (CheckInHabit and UseStreakFreeze interactions), which now publish conditionally and can suppress publication when appropriate (e.g., system-triggered repairs suppress publish to avoid duplicate event storms).

Follow-up: Background jobs publish domain events too — e.g., ProcessHabitStreaksJob triggers streak-lapse logic. The general rule is: interactions publish for user-triggered events; scheduled jobs publish for time-based events. Deferred to Phase 55+ for formal policy.

Recorded: 2026-05-01

Webhook-Driven vs. Direct-Call Coupling

A useful lens for planning Phase 2 (Domain Events): webhook-driven boundaries are already asynchronous and loosely coupled to the producer. They are the highest-leverage seams for introducing a unified event emitter, because the mental model (receive → translate → publish) is already the right shape.

BoundaryKindTodayStatus
Clerk → User IdentityWebhook (sync, signed)ACL service pairCandidate for event emission
Agent Runner → AI WorkforceWebhook (HMAC)ProcessWebhookResult interactionCandidate for event emission (Phase 54+)
Stripe → BillingWebhook (signed)Unmediated in controllerCandidate for ACL + event emission (Phase 54+)
Goal Tracking → Gamification (CheckInHabit)Event-driven✅ Emit goal_tracking.habit_checked_inImplemented in v1.12 Phase 53
Goal Tracking → Gamification (AddGoal badges)Direct callUserAction.create_first_goal_action_jobCandidate for goal.created event

Last updated: 2026-09-15 — added Import/Export as a new node, conformist read-only edge to Goal Tracking, and Coupling Summary row (v4.64 Phase 2, OBJ-3822).

Previously: 2026-09-11 — added Guide Assistant (sub-context of AI Coaching) as a node with no cross-context edges (OBJ-3629).

Loading…