Skip to content

Bounded Contexts

Objectuve decomposes into 12 bounded contexts. Each context owns a cluster of models, interactions, and business rules. This document defines what each context is responsible for and what it owns.

The contexts are:

  1. Goal Tracking (Core Domain)
  2. Social & Community (Accountability)
  3. User Identity & Profile (Shared Kernel)
  4. Gamification & Progression (Supporting)
  5. AI Coaching (Supporting — per-user ephemeral prompts)
  6. AI Workforce (Platform Infrastructure — autonomous scheduled agents) (added since initial DDD pass)
  7. Billing (Supporting — Supporter/Teams payments via Stripe) (added since initial DDD pass)
  8. Feedback (Supporting — user voice and roadmap input) (added since initial DDD pass)
  9. Critical Path (Supporting — daily-puzzle mini-game producing stim-XP and stim-streak progression) (added since initial DDD pass)
  10. Integrations & Connected Apps (Supporting — anti-corruption layer for third-party activity providers) (added since initial DDD pass)
  11. Personal Analytics (Supporting — read-model dashboard over other contexts' data) (added since initial DDD pass)
  12. Import/Export (Supporting — in-request CSV staging for GDPR-export re-import) (added since initial DDD pass)

Cross-cutting concerns (not standalone bounded contexts):

  • Content Moderation — attaches polymorphically to content in Social & Goal Tracking
  • Admin — cross-cutting administrative actions, role management, and reporting queries
  • GDPR & Data Rights — fulfills data-subject rights (access, deletion, export) across all contexts; reads from all bounded contexts to assemble cascade previews and exports

How to read this: For each context, we list its aggregate roots (top-level entities that enforce invariants), entities (objects with identity), value object candidates (data that should be treated as immutable), application services (interactions), and the database tables it owns.


1. Goal Tracking (Core Domain)

Purpose: Everything related to creating, tracking, and completing goals — including habit management, milestones, progress logging, and streak mechanics.

This is the core domain — the reason the product exists. It should receive the most modeling attention and be the most carefully protected from external coupling.

Aggregate Roots

Goal (app/models/goal.rb)

  • The central entity. Owns events, milestones, habit completions, media, and community goal associations.
  • Enforces: name presence/length, content length, recurrence validation, days_to_update calculation.
  • Lifecycle callbacks: creates initial event on creation, creates completion event on completion, updates completed_at.
  • Key methods: habit?, checked_in_today?, due_today?, completion_rate(days), update_habit_streak!, past_due?, needs_reminder?

GoalEvent (app/models/goal_event.rb)

  • Immutable progress log entry. Once created, the content represents a point-in-time record.
  • Owns: encouragements, comments, media.
  • Created automatically (goal creation, completion, habit check-in) and manually (user progress updates).

Entities

EntityPurposeKey Fields
MilestoneSub-goal linked to parent via parent_goal_idpublic_idInherits from Goal
HabitCompletionOne-per-day check-in recordgoal_id, completed_date (unique pair), streak_freeze_used
GoalEventEncouragement"Like" on an event (soft-deleted for toggle)goal_event_id, user_id (optional)
GoalEventReactionEmoji reaction on an event (16 allowed emojis). Soft-deleted for toggle. One per user per event (swappable).goal_event_id, user_id, emoji
GoalEventCommentText reply on an eventgoal_event_id, user_id, content
GoalEncouragementGoal-level encouragement (distinct from event-level). Optional comment text.goal_id, user_id (optional), comment
GoalMediaImage attached to an eventgoal_event_id, image_url
GoalCollageVisual progress collagegoal_id

Reference Data

ModelPurpose
GoalTypeClassification of approach (habit, milestone, quantity). Aliased as kind.
GoalCategoryBroad grouping (fitness, learning, personal). Shared with Community context.

Value Object Candidates

These are currently raw fields on Goal but represent coherent concepts that could be extracted:

  • RecurrenceSchedulerecurrence_type + recurrence_days + recurrence_interval. Encapsulates the "when is this habit due?" logic including due_today? and expected_completions.
  • StreakStatehabit_streak + longest_habit_streak + streak_freezes_available + streak_freezes_used. Encapsulates streak calculation and freeze token logic.
  • CompletionRate — the output of Goal#completion_rate(days). Currently a computed float, could be a value object with period context.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
AddGoalCreate a goal with type, category, recurrenceTriggers badge jobs → Gamification
UpdateGoalEdit goal properties, soft/hard deleteNone
AddGoalEventLog progress updateTriggers GenerateFollowFeedItemsJobSocial
UpdateGoalEventEdit/delete progress entryNone
CheckInHabitOne-tap daily habit completionCalls GamificationService.award_xpGamification, triggers feed job → Social
UseStreakFreezeApply freeze token to missed dayNone
CalculateGoalProgressGroup events by period for analyticsNone (read-only)

Owned Tables

goals, goal_events, goal_media, goal_collages, habit_completions, goal_event_encouragements, goal_event_comments, goal_types, goal_categories, milestones (same table as goals, filtered by parent_goal_id)

Design Decision: Habits as Goal Mode

Habits are not a separate model — they're goals with recurrence_type set. This keeps the schema simple and avoids a parallel entity hierarchy. The trade-off: the Goal model (257 lines) handles both goal lifecycle and habit lifecycle logic.

Extraction trigger: If habit-specific logic (streaks, freeze tokens, recurrence scheduling, completion tracking) exceeds ~50% of the Goal model, consider extracting a Habit concern or decorator that wraps Goal.


2. Social & Community (Accountability)

Purpose: Everything related to connecting users for mutual accountability — communities, allyships, goal following, posts, feeds, notifications, and activity aggregation.

This is a single context because communities, allies, follows, and feeds all serve the same purpose: "accountability through others." The data flows naturally between them (allies are often community co-members, feed aggregates both, notifications span both).

Aggregate Roots

Community (app/models/community.rb)

  • Interest-based group bound to a GoalCategory. Owns members, posts, events, goals (via join), and feed items.
  • Computed metrics: health_score (weighted engagement formula), growth_rate, badges (platform distinctions).
  • Key invariant: name must be 5-50 characters, must have a category.

CommunityPost (app/models/community_post.rb)

  • Discussion thread within a community. Types: discussion, achievement, milestone.
  • Owns: comments, reactions.

UserAlly (app/models/user_ally.rb)

  • Bidirectional peer relationship. Status lifecycle: pendingaccepted | blocked.
  • Unique constraint on (user_id, ally_id).

Entities

EntityPurpose
CommunityMemberUser membership with role (admin/moderator/member) and points
CommunityGoalJoin table linking a Goal into a Community
CommunityEventScheduled event within a community
EventAttendeeRSVP for a community event
CommunityFollowNotification subscription to a community
CommunityFeedItemActivity stream entry within a community
CommunitySuggestionUser-submitted community recommendation
CommunityMediaImage on a community feed item
PostCommentReply to a community post
PostReactionEmoji/like reaction to a post
UserGoalFollowSubscription to another user's public goal
UserFeedItemPersonal activity stream entry (badge earned, encouragement received, etc.)
UserNotificationIn-app notification with kind enum and JSONB details
FeedKindReference/enum model categorizing feed item types

Value Object Candidates

  • HealthScore — the output of Community#health_score. Currently a computed integer, could encapsulate the formula and component breakdown.
  • GrowthRate — week-over-week member growth percentage.
  • AllyStatuspending | accepted | blocked. Currently a raw string validated on UserAlly.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
CreateCommunityCreate community with founder as adminNone
JoinCommunityAdd user as member, create feed itemTriggers badge job → Gamification
LeaveCommunityRemove membershipNone
FollowCommunitySubscribe to community notificationsNone
UnfollowCommunityUnsubscribeNone
CreateCommunityPostPost in a communityNone
AddGoalToCommunityShare a goal into a communityReads from Goal Tracking
AddCommunitySuggestionSubmit community recommendationNone
ToggleFollowGoalFollow/unfollow a public goalReads from Goal Tracking
ToggleGoalEventEncouragementLike/unlike a goal eventReads from Goal Tracking, triggers notification
ToggleGoalEventReactionToggle emoji reaction on a goal eventReads from Goal Tracking, triggers notification and feed item
AddGoalEventCommentComment on a goal eventReads from Goal Tracking, triggers notification
AddPostCommentComment on a community postTriggers content screening via ContentModerationService
BuildUnifiedFeedAggregate feed from allies, communities, own goalsReads from Goal Tracking, User Identity
SendPushNotificationDeliver push notificationReads from User Identity (devices)
AcknowledgeNotificationMark a notification as readNone
AcknowledgeAllNotificationsBatch mark-all-read, optional team_id scopeNone
DeleteNotificationRemove a notificationNone
BuildNotificationHistoryPaginated notification history + unread count, optional team scopeNone

Query Service: BuildUnifiedFeed

This is the most cross-cutting service in the system. It reads from four sources within a 14-day lookback:

  1. Ally activityGoalEvents from accepted allies' public goals (via UserAlly)
  2. Community postsCommunityPosts in communities the user belongs to
  3. User feed itemsUserFeedItem records (notifications, badges)
  4. Own activity — the user's own public goal events

All items are normalized to a common hash shape, sorted by timestamp, and paginated. This is effectively a CQRS read model — it doesn't write data, only projects from multiple aggregates.

Owned Tables

communities, community_members, community_posts, community_events, event_attendees, community_goals, community_feed_items, community_media, community_follows, community_suggestions, post_comments, post_reactions, user_allies, user_goal_follows, user_feed_items, user_notifications

Key Decision: Notification Management Ops Move to Social:: (OBJ-2950)

Decision: AcknowledgeNotification and AcknowledgeAllNotifications moved from Gamification:: to Social::, joining the pre-existing DeleteNotification and BuildNotificationHistory. All four notification-management interactions now live in the namespace that owns UserNotification.

Reasoning: UserNotification is owned by Social & Community (rails_api/app/models/user_notification.rb:3), stated independently in the model header, this context's owned-tables list above, and the social entry's models: glob in scripts/arch-domains.mjs. The two acknowledge interactions had sat under Gamification:: since their addition, for no reason the code states — a historical accident of the shared verb "Acknowledge," which Gamification::AcknowledgeAction also uses for badges. There is no rule that produces "acknowledge is Gamification, delete is Social" as anything but a list of exceptions. The rule that generalizes — an interaction lives in the namespace of the context that owns the aggregate it mutates — is now stated in 05 — Application Services § Namespace Placement Rule, with UserNotification's produce-vs-manage split as its worked example.

Why now: the split had already fragmented audit scope once — the gamification audit reviewed AcknowledgeNotification while the social audit reviewed DeleteNotification, and neither ever saw the whole notification write surface — and Codi's OBJ-2948 keystone check flagged the same inconsistency a second time. Pure namespace move, zero logic change; no GraphQL contract change (mutation names, arguments, and payload types are byte-identical).


3. User Identity & Profile

Purpose: User accounts, authentication, profile management, sign-in tracking, and device registration. The "who is this person?" context.

Aggregate Root

User (app/models/user.rb)

  • Central entity referenced by every other context. Owns goals, communities (via membership), devices, accounts, photo, details, and sign-in history.
  • Key fields: username, email, first_name, last_name, clerk_user_id, admin, email_verified.
  • Also holds gamification state (xp, level, current_streak, longest_streak) — see design decision below.

Entities

EntityPurpose
UserAccountRemoved — OAuth is now managed entirely by Clerk. The clerk_user_id field on User links to the Clerk-managed identity.
UserDetailExtended profile metadata (JSONB)
UserPhotoProfile image
UserDevicePush notification token (platform, token)
UserSignInSign-in aggregation record
UserSignInHistoryPer-day sign-in log
CheckinTokenDevice-scoped credential authenticating background widget check-ins (device_id, token_digest). Deliberately single-scope (SCOPES = %w[check_in_write]) so it can never widen into a general session credential — v4.22 Phase 1 ADR. 90-day expiry (EXPIRY), soft-deleted via acts_as_paranoid.
MagicCodeOne-time 6-character code powering magic-link sign-in. 10-minute expiry (EXPIRY), rate-limited to 3 requests per window (MAX_REQUESTS_PER_WINDOW). Plain ApplicationRecord — no public_id.

Domain Services

ServicePurpose
ClerkJwtVerifierVerify RS256 JWTs from Clerk via JWKS endpoint. Anti-corruption layer for external auth.
ClerkUserSyncFind-or-create local User from Clerk profile on first sign-in.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
ClerkUserSyncSync Clerk user to local DBNone (pure identity)
UpdateUserEdit profile (name, username)Syncs to Clerk (external)
UpdateUserPhotoProfile image uploadNone
StoreUserDetailsExtended profile metadataNone
StoreDeviceTokenRegister push notification tokenNone
RecordSignInTrack sign-in, update streakCalls GamificationService.award_xpGamification

Owned Tables

users, user_accounts, user_details, user_photos, user_devices, user_sign_ins, user_sign_in_histories, checkin_tokens, magic_codes

Design Decision: Gamification State on User

The users table holds xp, level, current_streak, and longest_streak. These logically belong to the Gamification context but live on User for persistence convenience — avoiding an extra join on every authenticated request.

Extraction trigger: When gamification rules become complex enough to need their own aggregate (e.g., XP multipliers, seasonal bonuses, achievement trees), extract a UserProgression model or table that the Gamification context owns, with User Identity holding only a cached level for display.

Enneagram (sub-context of User Identity & Profile)

Purpose: Personality self-assessment that produces a stored Enneagram profile (dominant type 1–9, wing, tritype, per-type scores). Profile data is owned by User Identity because it is a durable attribute of the user, not a transient AI output. AI Coaching reads it as a conformist dependency to personalize coach persona and prompts.

Aggregate

EnneagramAssessment (app/models/enneagram_assessment.rb)

  • Stores a completed assessment result. History preserved — retakes create new rows; soft-deleted via acts_as_paranoid.
  • Key fields: scores (JSONB — normalized 0–100 per type), dominant_type (1–9), wing (adjacent type), tritype (e.g., "583"), completed_at.
  • Enforces: dominant_type and wing in 1–9, wing must be adjacent to dominant type, tritype must have exactly one digit per Enneagram center (Body/Heart/Head), scores must be a 9-key hash with integer values 0–100.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
SubmitEnneagramAssessmentScore 40 forced-choice answers, compute dominant type / wing / tritype, persist EnneagramAssessmentNone (writes to User Identity's own aggregate)
DismissEnneagramCardMark UserDetail.enneagram_card_dismissed = true to suppress the assessment promptWrites to UserDetail (User Identity)

Convention note: Both interactions are currently top-level (unnamespaced — app/interactions/submit_enneagram_assessment.rb, app/interactions/dismiss_enneagram_card.rb). They should be UserIdentity::SubmitEnneagramAssessment / UserIdentity::DismissEnneagramCard. A namespace refactor is a separate follow-up for Codi.

Owned Tables

enneagram_assessments


Weekly Digest (sub-context of User Identity & Profile)

Purpose: User preferences for the weekly email digest (opt-in/out, preferred delivery day) and the cross-context assembly of digest content. Preferences are stored on UserDetail (User Identity). WeeklyDigest::AssembleDigestData orchestrates reads from User Identity (coaching preferences), Gamification (weekly XP, streak), Goal Tracking (active goal CTA), Social (recent ally activity), and AI Coaching (AI-generated personalized insight) to build the digest payload.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
WeeklyDigest::FetchWeeklyDigestPreferences (app/interactions/weekly_digest/fetch_weekly_digest_preferences.rb)Return enabled flag and delivery_day from UserDetail.data (defaults: enabled=true, delivery_day='sun')None (reads User Identity's own UserDetail)
WeeklyDigest::SetWeeklyDigestPreferences (app/interactions/weekly_digest/set_weekly_digest_preferences.rb)Write weekly_digest_enabled and weekly_digest_delivery_day onto UserDetail; valid delivery days: sun, sat, monNone (writes User Identity's own UserDetail)
WeeklyDigest::AssembleDigestData (app/interactions/weekly_digest/assemble_digest_data.rb)Assemble full digest payload: AI insight (via Ai::ServiceClient, batch/insights model), streak count, weekly XP (StimXpLedger), goal CTA URL, recent ally activityReads from Gamification (StimXpLedger), Goal Tracking (user.goals), Social (ally list → GoalEvent activity), AI Coaching (Ai::ServiceClient, Ai::Prompts::WeeklyDigest). Falls back to Ai::Fallbacks::Insights on AI error; emits weekly_digest_insight_fallback PostHog event on fallback.

Owned Tables / Fields

No dedicated Weekly Digest table. Preferences (weekly_digest_enabled, weekly_digest_delivery_day) live as JSONB store_accessor fields on user_details.data (User Identity).


4. Gamification & Progression

Purpose: XP, levels, badges, achievement detection, and the reward mechanics that make goal tracking feel like a game. This is a supporting domain — it reacts to events from other contexts rather than driving its own workflows.

Aggregate Root

UserAction (app/models/user_action.rb)

  • Represents a badge/achievement unlock. Each action has an action enum value, an acknowledged flag, and belongs to a user.
  • Generated once per user per action type. Triggers a UserFeedItem and notification.

Badge Types (UserAction enum)

BadgeTrigger
first_sign_inUser's first login
create_first_goalFirst goal created
complete_first_goalFirst goal completed
complete_fifth_goal5th goal completed
complete_tenth_goal10th goal completed
create_second_typeGoals in 2 different types
create_second_categoryGoals in 2 different categories
join_first_communityFirst community joined
consistency_is_keySustained streak
habit_formerHabit completion milestone
repair_streakRebuilt streak after gap (via RepairStreak interaction)
award_partner_bonusPartner-bonus XP milestone (via AwardPartnerBonus subscriber)
dedicated, supporter, super_fanEngagement tiers
visionary, achiever, explorer, innovator, planner, executorArchetype badges
early_birdEarly adopter

Domain Service

GamificationService (app/services/gamification_service.rb)

  • award_xp(user, amount, reason) — increment XP with row-level locking, check for level-up.
  • check_for_level_up(user) — loop: if XP >= next threshold, increment level, trigger notification.
  • calculate_threshold(level) — lookup table: [0, 500, 1000, 2000, 3500, 5500, 8000, 11000, 15000, 20000, then +5000/level].

Background Jobs

JobTriggerPurpose
CreateFirstGoalActionJobAddGoal (first goal)Award create_first_goal badge
CreateSecondCategoryActionJobAddGoal (second category)Award create_second_category badge
CreateSecondTypeActionJobAddGoal (second type)Award create_second_type badge
JoinFirstCommunityActionJobJoinCommunity (first)Award join_first_community badge
CreateLevelUpNotificationJobGamificationService.check_for_level_upSend level-up notification
ProcessHabitStreaksJobCrono (daily 08:00)Recalculate all active habit streaks; lapse streaks (LAPSE_THRESHOLD_DAYS = 5 days for user-level lapse; streak breaks on any missed day); publishes gamification.streak_broken and gamification.streak_lapsed events
CheckBadgesJobgamification.streak_advanced event subscriberEvaluate and award streak milestone badges (7, 14, 30, 60, 90 day milestones)
StreakBrokenJobgamification.streak_broken event subscriberAward resilience/comeback badges when user breaks a streak
CreateFeedbackBadgeJobFeedback::CreateFeedbackPost (direct enqueue)Award voice_heard / community_voice badge when user submits feedback
FoundingMemberBadgeJobEligible user check (early sign-ups)Award founding member status badges

Application Services (Interactions)

InteractionPurpose
AcknowledgeActionMark a badge as seen
UpdateShowcasedAchievementsCustomize profile badge showcase
RepairStreakRebuild missed habit completions for a user; triggered after vacation or gap. Loops through missed days, calls UseStreakFreeze equivalent, publishes gamification.streak_repaired domain event, awards repair_streak milestone badge
AwardPartnerBonusAward XP bonus when both allied users check in on the same day; called from Gamification::Subscribers after goal_tracking.habit_checked_in event fires
GrantStarterFreezeIssue one free streak freeze token to new users in their first week; triggered from UserIdentity::ClerkUserSync

Design Decision: Scattered Badge Detection

Badge detection currently happens inline in interactions (AddGoal checks goal count, triggers job) and in background jobs. Each new badge type requires modifying the triggering interaction.

Proposed improvement: Centralize badge evaluation. Introduce a BadgeEvaluator that subscribes to domain events (GoalCreated, CommunityJoined, etc.) and evaluates all applicable badge rules. See 04 — Domain Events for the event catalog.


5. AI Coaching (Supporting Domain)

Purpose: Per-user AI-powered assistance for goal setting and progress. Generates milestones, refines descriptions, provides advice, delivers personalized check-in prompts, and maintains conversational memory across coaching sessions. This context also houses the shared LLM generation seam — Ai::ServiceClient, its per-category kill switches and rate-limit categories, and AiUsageEvent cost tracking — used by every AI feature in the app, which is why sub-contexts like Guide Assistant below are grouped here for that seam rather than because their output is coaching.

This is a supporting domain that consumes data from Goal Tracking and User Identity and — as of the Coach Surfaces milestone — owns persistent conversation state (CoachConversation / CoachMessage) with a 90-day rolling retention window, a generation audit log (CoachInteraction), and append-only cost metrics (AiUsageEvent).

Not to be confused with AI Workforce (context 6 below). AI Coaching fires on user request (or once-daily per user via Crono) and is scoped to a single user's goals. AI Workforce is fundamentally different: persistent AiEmployee aggregates running on independent schedules with budgets, memory, and operator approval workflows.

Persistent Domain State

ModelPurposeKey Fields / Notes
CoachConversation (app/models/coach_conversation.rb)Rolling conversation thread per (user, goal_id) pair. One global thread when goal_id is nil.Soft-deleted via acts_as_paranoid. Unique index on (user_id, goal_id) NULLS NOT DISTINCT prevents duplicates under concurrent creates.
CoachMessage (app/models/coach_message.rb)Single turn in a conversation. role: user or assistant.MAX_HISTORY_TURNS = 10, MAX_HISTORY_CHARS = 2_400 (≈600 tokens). Soft-deleted via acts_as_paranoid. GDPR: cascade-deletes with conversation.
CoachInteraction (app/models/coach_interaction.rb)Append-only audit log of every Coach generation (the text the user saw). Admin/internal only — inherits ApplicationRecord, no public_id, never exposed via API.Features audited: coaching, milestones, insights, check_ins, description, welcome_back, compassionate_break, coach_synopsis. GDPR: cascades on user deletion.
CoachTriggerDispatch (app/models/coach_trigger_dispatch.rb)Per-(goal, trigger_kind, period) dedup record preventing a coaching trigger (e.g. at-risk nudge) from firing twice in the same period. Internal bookkeeping only — inherits ApplicationRecord, no public_id, never exposed via GraphQL.Unique index on (goal_id, trigger_kind, period_key) backs EnsureAtRiskGoalCoachMessage#already_dispatched?'s check-then-act pattern.
AiUsageEventAnonymous, append-only cost/token tracking per LLM call. Shared with AI Workforce — see context 6.Internal only; inherits ApplicationRecord.
AiRequest (app/models/ai_request.rb)Durable correlation row for one async AI request, routed off the request thread instead of blocking a Puma worker for the length of an LLM call (v4.60, Async AI Delivery Path). Not a history table — coach_messages/CoachInteraction above already own conversation and generation history.PublicRecord, acts_as_paranoid, belongs to User. status enum: pending / succeeded / failed. See Async AI Delivery Path below.

Retention: CoachConversation::RETENTION_DAYS = 90. Conversations and their messages are pruned on a rolling 90-day window.

Domain Service

Ai::CoachService (app/services/ai/coach_service.rb)

  • generate_milestones — suggest sub-goals for a given goal
  • generate_goal_draft — draft a full goal (name/description/category/kind) from a free-text idea
  • refine_description — improve goal description text
  • get_advice — contextual coaching advice
  • generate_insight — page-scoped insights (dashboard, goals, achievements, communities)
  • generate_synopsis — periodic coach synopsis for a user
  • generate_at_risk_nudge — nudge for a goal at risk of lapsing
  • generate_welcome_back — re-engagement message after a lapse
  • generate_compassionate_break — message after a broken streak
  • generate_check_in_prompt — personalized daily check-in prompt

Background Job

JobSchedulePurpose
AiCoaching::GenerateDailyInsightPackJobCrono (daily 06:00)Pre-generate the day's coach insights per user
AiCoaching::GenerateAtRiskCoachMessageJobCrono (hourly)Detect at-risk goals and dispatch a coach nudge (dedup via CoachTriggerDispatch)
AiCoaching::GenerateFollowUpCoachMessageJobEvent-triggered (goal_tracking.habit_checked_in via Ai::Subscribers)Generate a completion follow-up coach message after a habit check-in
AiCoaching::RecordCoachInteractionJobEnqueued inline from Ai::ServiceClientPersist the append-only CoachInteraction audit record; fail-closed with Sidekiq retry
AiCoaching::GenerateAiCheckInPromptsJobCrono (daily 10:00), gated to AI_CHECK_IN_INTERNAL_ALLOWLIST while rampingGenerate personalized coaching prompts, deliver as notifications. Sole trigger for the ai_check_in notification kind — fires the 5 frontend surfaces that key off it (SideMenu, BottomTabBar, CoachCheckInSection, CheckInPromptCard, Coach view), scoped to the allowlisted internal cohort until GA (v4.4 §6-10, OBJ-1319).
AiMetrics::AiBudgetAlertJobCrono (hourly)Alert when AI spend approaches budget thresholds
AiMetrics::RecordAiUsageEventJobEnqueued inline from Ai::ServiceClientPersist the append-only AiUsageEvent cost/token record; fail-closed with Sidekiq retry
Ai::DispatchAiRequestJobEnqueued per async dispatch (feature-flag-gated, see below)Runs the AiRequest#kind's handler from Ai::AiRequestHandlerRegistry, persists succeeded/failed, then broadcasts on Subscriptions::AiRequestUpdate either way
Ai::PurgeCompletedAiRequestsJobCrono (daily 04:15 UTC)Hard-deletes succeeded/failed AiRequest rows past the 7-day retention window; never touches pending rows
Ai::ReportStrandedAiRequestsJobCrono (every 15 min)Read-only stranded-AiRequest gauge for the v4.60 soak instrument — emits an ai_request_stranded_gauge PostHog event, no writes

GraphQL Mutations

  • generate_milestones — returns suggested milestones for a goal
  • generate_goal_draft — returns a drafted goal from a free-text idea
  • refine_description — returns improved goal description text
  • get_advice — returns coaching response
  • get_insight — returns page-contextual insight (resolves via Ai::CoachService#generate_insight)
  • ensure_todays_coach_message — returns (or lazily generates) today's coach message
  • update_coaching_preferences — updates a user's coaching preferences

Dependencies

  • Reads Goal data (name, description, events, milestones, completion rate)
  • Reads User data (level, streak, goal count, coaching preferences, Enneagram profile via EnneagramAssessment)
  • Writes: CoachConversation, CoachMessage (persistent conversation memory, 90-day retention); CoachInteraction (generation audit log); CoachTriggerDispatch (per-period trigger dedup); UserNotification (for AI check-in prompts); AiUsageEvent (append-only cost/token tracking, shared with AI Workforce infrastructure); AiRequest (async request correlation row, 7-day retention on completed rows — see Async AI Delivery Path below)

Cost Tracking

AiUsageEvent is an internal, append-only metrics aggregate (inherits ApplicationRecord, not PublicRecord — admin-only, never exposed via API). It tracks token usage across the full AiUsageEvent::FEATURES feature set: coaching, milestones, insights, check_ins, description, moderation, daily_insight_pack, coach_synopsis, coach_at_risk_nudge, compassionate_break, goal_draft, goal_suggestions, feedback_summary, welcome_back, weekly_digest, goal_quality_check, quick_updates, docs_assistant. It is written both by AI Coaching and by AI Workforce runs and therefore sits in the shared infrastructure space between the two contexts.

Owned Tables

coach_conversations, coach_messages, coach_interactions, coach_trigger_dispatches, ai_usage_events (shared with AI Workforce; see context 6), ai_requests

Async AI Delivery Path (sub-context of AI Coaching)

Purpose: Durable substrate for moving AI mutation work off the request thread, so a GraphQL mutation doesn't hold a Puma worker and its checked-out ActiveRecord connection for the length of an LLM call (v4.60, Async AI Delivery Path milestone). General async-request infrastructure shared by every AI mutation that opts in — not part of the coaching inference layer itself, which is why it's documented separately from the Domain Service above even though AiRequest lives in this same ai_coaching context.

Delivery path, per mutation: gated by the async_ai_delivery_enabled PostHog flag, checked inside resolve after the existing kill-switch/rate-limit guards. Off (or flag absent): the mutation resolves synchronously against Ai::CoachService, unchanged from before this milestone. On: the mutation creates an AiRequestsucceeded immediately with its result populated on a cache hit (no job enqueued), otherwise pending — and enqueues Ai::DispatchAiRequestJob.perform_later(ai_request.public_id). All seven Ai::CoachService-backed mutations (getAdvice, generateMilestones, generateGoalDraft, refineGoal, refineDescription, suggestGoals, getInsight) carry this dual path today and expose it via an aiRequest: AiRequestType field alongside their existing synchronous-path fields.

Lifecycle: pendingAi::DispatchAiRequestJob looks up the row's kind in Ai::AiRequestHandlerRegistry, runs the registered handler, and persists the outcome — succeeded (with result) or failed (with error_code/error_message) — setting completed_at either way. The job broadcasts on Subscriptions::AiRequestUpdate regardless of outcome, so a subscriber isn't left hanging past a handler failure. Fail-open by construction: a handler raising never bubbles past the job. Idempotency guard: the job no-ops if the request is missing or no longer pending, so a Sidekiq retry can't re-run an already-completed request's handler.

GraphQL surface: aiRequest(id:) query — the "correct but slow" recovery path pairing the subscription's "fast but unreliable" push; owner-only, returns nil (not an error) for a foreign or unknown id so a guessed id can't be used to probe existence — and the aiRequestUpdate(requestId:) subscription, keyed by request id and authorized to that request's owner only.

Retention/purge: AiRequest::RETENTION_WINDOW = 7 days. Ai::PurgeCompletedAiRequestsJob (Crono, daily 04:15 UTC) hard-deletes succeeded/failed rows past the window via the retention_eligible scope; pending rows are never touched by this sweep, regardless of age — an AiRequest's job is correlation and reconnect-recovery, not history.

Stranded-gauge instrumentation (OBJ-3972, v4.60 soak instrument): a pending row only stays pending if Ai::DispatchAiRequestJob never ran or the process died mid-handler (queue backlog, worker restart, deploy, OOM) — the job does one bounded LLM call and rescues StandardError into a failed row otherwise. The stranded scope selects pending rows older than AiRequest::STRANDED_THRESHOLD (15 minutes); STRANDED_SEVERE_THRESHOLD (60 minutes) is the "definitely stranded" severity band. Ai::ReportStrandedAiRequestsJob (Crono, every 15 min) is read-only — it never writes, completes, or purges rows — and emits one ai_request_stranded_gauge PostHog event (stranded_count, stranded_severe_count, pending_total, oldest_pending_age_minutes, threshold_minutes) so the soak dashboard reads a real number without needing DB or Rails console access.

Owned Table: ai_requests

Guide Assistant (sub-context of AI Coaching)

Purpose: Public, unauthenticated "Ask the guide" Q&A over the Objectuve guide (help.objectuve.com) — v4.55 Phase 3. Answers are grounded only in a committed guide corpus; the assistant refuses rather than answering from outside knowledge.

Interaction: Ai::DocsAssistant::AnswerGuideQuestion (app/interactions/ai/docs_assistant/answer_guide_question.rb) — builds the system prompt from the corpus, calls Ai::ServiceClient.chat (model docs/assistant, feature docs_assistant), detects a refusal, and validates every [source: <slug>] citation against the corpus before returning it. A cited slug that doesn't resolve to a real corpus page is dropped, never returned — a prompt-injection floor, since corpus content is treated as attacker-influenced. Questions are capped at MAX_QUESTION_LENGTH = 1000 characters.

GraphQL query: askGuideQuestion(question: String!): GuideAssistantAnswer! (Types::QueryType#ask_guide_question). Checks the docs_assistant_enabled kill switch (Ai::SettingsGuard) before the rate limiter, so a disabled assistant never consumes rate-limit budget. Returns one of three terminal states (Types::GuideAssistantStateEnum) — ANSWERED, REFUSED (out-of-scope question, citations always empty), DISABLED (kill switch off, answer null) — all as successful responses, never GraphQL errors; only rate-limiting surfaces as a RATE_LIMITED GraphQL error.

Grounding source — the guide corpus: rails_api/config/ai/guide_corpus.json, a committed JSON snapshot of guide_site/*.md (22 pages at the time of writing), generated by scripts/build-guide-corpus.mjs. It is committed rather than read live because rails_api/'s Docker build context can't see guide_site/ — the running container has no path to the real guide markdown. .github/workflows/guide-corpus-drift.yml regenerates the snapshot in CI and diffs it against the committed copy to catch drift between the guide and what the assistant can cite.

Abuse-control layers (three, independent):

  1. GraphQL-layer limit — 20 requests/min per caller (user or IP), surfaced as a graceful RATE_LIMITED GraphQL error (Types::QueryType#ask_guide_question).
  2. Rack-attack IP throttle — 30 requests/min per IP, deliberately coarser than the GraphQL-layer limit (config/initializers/rack_attack.rb, guide-assistant-ip throttle). Rejects a flood at the Rack boundary — before Rails routes, parses, and executes a full GraphQL query — for a caller blowing past the graceful limit.
  3. Ai::BudgetGuard monthly cost ceilingSettings.ai.docs_assistant_monthly_budget_cents (the generic, opt-in mechanism shared with every AI feature). Once month-to-date spend for the docs_assistant feature reaches the ceiling, Ai::ServiceClient.chat returns nil rather than calling LiteLLM. This is the real cost backstop; the two throttles above bound request volume, not spend.

Owned Tables: none (stateless). AnswerGuideQuestion reads only the committed corpus and writes only the shared AiUsageEvent metric via Ai::ServiceClient's own usage-recording path — it has no model, no migration, and no persisted domain state of its own.

Boundary note: Guide Assistant reads no user data and no goal data — it is a single stateless Interaction over a public, read-only document corpus. It is listed in CoachInteraction::AUDIT_EXEMPT_FEATURES alongside moderation and feedback_summary — like both of those, that exemption is a statement about audit scope (its output isn't Coach-persona text, so it isn't sampled for Coach voice-quality auditing), not a statement about bounded-context boundaries.

Extraction trigger: Promote Guide Assistant to a peer bounded context if it acquires persistence (conversation threads, answer feedback, a retrieval index rather than full-context stuffing), begins reading user or goal data, or ships a second Interaction that mutates state. Until then it stays a sub-context of AI Coaching.


6. AI Workforce (Platform Infrastructure)

Purpose: Autonomous scheduled AI employees that run on cron-like intervals, produce reviewable artifacts (draft posts, code patches, email templates, reports, recommendations), and — once promoted out of shadow mode — deliver approved artifacts to their destinations (GitHub PRs, Slack, Mailtrap, docs repo). The existing ~40 Claude Code skills become persistent agents with memory, per-employee budgets, and graduated autonomy.

Classified as Platform Infrastructure / Core because it is a durable business capability with its own aggregates, lifecycle, budget enforcement, and operator workflow — not merely a feature of another context.

Aggregate Roots

AiEmployee (app/models/ai_employee.rb)

  • The persistent configuration of an AI employee. Owns runs, artifacts (indirectly via runs), memory, and usage events.
  • Enforces: autonomy_level transitions (shadowsemi_autonomousautonomous) via the promotion_requirements_met validation; per-month budget cap (monthly_budget_cents); role uniqueness; customer-support role never eligible for promotion above shadow.
  • Key methods: current_month_cost_cents, budget_remaining_cents.
  • Scopes: active, scheduled, due_for_run.

AiRun (app/models/ai_run.rb)

  • The execution record of a single scheduled or manually-triggered run. Status lifecycle: queuedrunningsucceeded | failed | cancelled.
  • Owns AiArtifact records produced during the run.
  • Enforces: valid status, triggered_by presence (scheduler, manual, webhook).

Entities

EntityPurpose
AiArtifactDurable output of a run awaiting operator review. Kinds: draft_post, code_patch, email_template, report, recommendation. Approval statuses: pending, approved, rejected, auto_rejected.
AiEmployeeMemoryStructured JSONB memory injected into the agent system prompt at run time. Unique (ai_employee_id, memory_key). Capped at ~2,000 tokens when assembled.
AiUsageEventAppend-only cost and token record per LLM call. Shared with AI Coaching — see section 5 above.

Value Object Candidates

  • AutonomyLevelshadow | semi_autonomous | autonomous. Currently a validated string on AiEmployee alongside promotion rules that already encapsulate transition invariants.
  • MonthlyBudgetmonthly_budget_cents + current_month_cost_cents + budget_remaining_cents. Could encapsulate the per-employee budget enforcement logic that today lives across the model, BudgetAlertJob, and LiteLLM.
  • RunStatus — lifecycle enum with transition rules (today encoded ad hoc across TriggerRun, ExecuteRunJob, ProcessWebhookResult, TimeoutStaleRunsJob).

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
AiWorkforce::TriggerRunQueue a run for an employee (guards against duplicate queued/running runs)None
AiWorkforce::UpdateEmployeeUpdate employee configuration (name, schedule, budget, autonomy with validation)None
AiWorkforce::ApproveArtifactOperator approves an artifact, enqueues deliveryEnqueues DeliverArtifactJob (context-internal)
AiWorkforce::RejectArtifactOperator rejects an artifact (manual or auto via brand-voice post-filter)None
AiWorkforce::ProcessWebhookResultApply Agent Runner HMAC-signed webhook result to an AiRun (status, artifacts, cost)Webhook from Agent Runner (external)

Background Jobs

JobTriggerPurpose
AiWorkforce::ScheduleRunsJobCrono (every 5 minutes)Enqueue a run for every due_for_run employee
AiWorkforce::ExecuteRunJobFrom TriggerRunPOST to Agent Runner; halt after setting status to running (completion comes via webhook)
AiWorkforce::DeliverArtifactJobFrom ApproveArtifactShip approved artifact (GitHub PR via Octokit, Slack post, Mailtrap sandbox, docs commit)
AiWorkforce::BudgetAlertJobTriggered on high-cost runsPost Slack alert, deduplicated via Rails.cache
AiWorkforce::TimeoutStaleRunsJobCrono (periodic)Bulk-update stuck runs to failed via update_all

Domain Services

ServicePurpose
Ai::AgentRunnerClient (app/services/ai/agent_runner_client.rb)HTTP client dispatching runs to Agent Runner (Faraday, open_timeout:3, timeout:10, 202 fire-and-forget).
Ai::OidcAuthFetches OIDC identity tokens for authenticating to the Agent Runner service in GCP.
Webhooks::AiWorkforceControllerInbound endpoint for Agent Runner callbacks; verifies HMAC-SHA256 signature, then delegates to ProcessWebhookResult.

External Seam

The Agent Runner (agent_runner/ — a separate TypeScript Cloud Run service with no DB access) and LiteLLM proxy (for model routing + monthly caps) both sit outside the Rails boundary. Today the coupling to them is implicit: Ai::AgentRunnerClient and Ai::OidcAuth handle transport but there is no formal Anti-Corruption Layer mediating the Agent Runner's data model. The HMAC-verified webhook enters at ProcessWebhookResult, which mostly maps JSON directly onto AiRun and AiArtifact.

ACL recommendation: Introduce AiWorkforce::AgentRunnerAdapter (mirroring the Clerk* ACL pattern in User Identity) to translate Agent Runner response JSON into domain objects. This would keep Agent Runner field names, enum values, and error shapes from leaking into our domain models.

Owned Tables

ai_employees, ai_runs, ai_artifacts, ai_employee_memories, ai_usage_events (shared with AI Coaching)


7. Billing (Supporting Domain)

Purpose: Payment processing for Supporter (individual) and Teams (multi-seat) tiers, backed by Stripe. Provides checkout sessions, webhook-driven subscription lifecycle, and expiration of lapsed supporter entitlements.

Classified as a supporting domain — it isn't the reason the product exists, but it is the monetization surface and has distinct aggregates and lifecycles that don't belong to any other context.

Aggregate Roots

PaymentRecord (app/models/payment_record.rb)

  • One record per completed or refunded payment event from either provider (Stripe or RevenueCat). Belongs to User, optionally to Plan.
  • Fields: provider (stripe | revenuecat), status (pending | completed | failed | refunded), amount_cents, currency, provider_event_id (unique-indexed, idempotency guard for both providers), provider_product_id, store (RevenueCat: app_store | play_store). Stripe-specific: stripe_checkout_session_id, stripe_subscription_id, stripe_payment_intent_id, stripe_event_id.
  • Enforces: amount presence and positivity, provider in %w[stripe revenuecat], status in %w[pending completed failed refunded].

Plan (app/models/plan.rb)

  • Reference data for tiers (Supporter monthly/annual, Teams). Owns PaymentRecords.
  • Fields: name, slug (unique), price_cents, currency, interval, stripe_price_id, active.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
Billing::CreateCheckoutCreate a Stripe checkout session for a Plan, returning the redirect URLCalls StripeService (external, via service wrapper)
Billing::ProcessStripeWebhookIdempotent processor for Stripe webhook events; records stripe_event_id on PaymentRecord and publishes billing.payment_processedPublishes domain event — Billing::Subscribers mutates user plan + payment record
Billing::ProcessRevenueCatWebhookIdempotent processor for RevenueCat IAP webhook events (App Store / Google Play); maps product_id to Plan, records provider_event_id on PaymentRecord, and publishes billing.payment_processedPublishes the same billing.payment_processed event as Stripe; Billing::Subscribers applies the identical user-plan mutation. Event types: INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE, UNCANCELLATION, PRODUCT_CHANGE (grant), EXPIRATION, REFUND, PAUSED (revoke). See docs/operations/revenuecat-iap-setup.md

Background Jobs

JobTriggerPurpose
Billing::ExpireSupportersJobCrono (daily)Revoke supporter entitlements for users whose subscriptions have lapsed

External Seams

StripeStripeService (app/services/stripe_service.rb) wraps the stripe gem for checkout, subscription, and billing portal operations — used by Billing::CreateCheckout. Inbound webhook events arrive at Webhooks::StripeController, which as of milestone v1.5 is a thin delegator: it verifies the Stripe signature (via Stripe::Webhook.construct_event) and hands the verified Stripe::Event to Billing::ProcessStripeWebhook. The interaction handles idempotency via PaymentRecord.stripe_event_id (unique-indexed) and publishes billing.payment_processed; Billing::Subscribers applies the user-plan mutation and payment-record status update. Stripe SDK objects no longer leak past the controller. See 04 — Domain Events for the full payload contract.

RevenueCat — Native In-App Purchases via Apple App Store and Google Play. Inbound webhook events arrive at Webhooks::RevenueCatController, which verifies the request and delegates to Billing::ProcessRevenueCatWebhook. The interaction guards idempotency via PaymentRecord.provider_event_id (unique-indexed across all providers). Product IDs are mapped to Plan slugs via PRODUCT_MAP in the interaction. RevenueCat and Stripe share the billing.payment_processed domain event and the same Billing::Subscribers handler — the subscriber mutates User.supporter_until regardless of provider. See docs/operations/revenuecat-iap-setup.md for the full RevenueCat webhook and product-ID setup guide.

Cross-context reads: Both providers read User.public_find(id) via the Shared Kernel and Plan.find_by(slug:) from Billing's own aggregate. No new cross-context coupling is introduced by RevenueCat beyond what Stripe already requires.

Owned Tables

payment_records, plans

Key Decision: Supporter:: Folds Into Billing, Not a New Domain

Decision: The Supporter:: interaction namespace (Supporter::SetColorTheme, Supporter::ThemeCatalog) is registered under Billing in the architecture domain registry (scripts/arch-domains.mjs) rather than as its own supporter domain. Billing's registry title becomes "Billing & Supporter Entitlements"; its id is unchanged (billing).

Reasoning: The Supporter:: namespace is entitlement-scoped, not merely preference-scoped — ThemeCatalog.supporter_only? gates most theme slugs, and Supporter::SetColorTheme guards on user.supporter? before permitting a supporter-only theme. Billing::ExpireSupportersJob already mutates the same user-owned entitlement fields (supporter_tier, supporter_until) that user.supporter? reads, so SetColorTheme's write into UserDetail (a different context's table) is a pre-existing consistent pattern, not a new exception. A single-file supporter domain would also cost real rotation budget in the registry's periodic gate-rescore cycle (2 domains/run).

Documented runner-up: user_identity. Under a strict reading this is arguably the cleaner placement — Supporter::SetColorTheme writes UserDetail, which User Identity already owns (see context 3), so that placement would produce no cross-domain write at all. billing was chosen on domain-concept grounds (this is an entitlement concern, not an identity concern) over gate-arithmetic grounds. If this is revisited, it is a one-line move in the registry — the decision doesn't block anything else.


Teams (sub-context of Billing)

Previously undocumented — this was the largest single doc-coverage gap found in the v4.3 Keystone Sweep (g30). In-code comments consistently tag every Teams model and interaction # Context: Billing (Teams), so this is documented as a sub-context of Billing (the same pattern as Enneagram and Weekly Digest under User Identity in context 3), not a 10th top-level bounded context.

Purpose: The multi-seat Team billing tier — team creation and seat management (Stripe-backed), member invites and roles, Team-scoped "sub-community" rooms, collective goals with per-member contribution tracking, team leaderboards, and a quarterly internal pulse survey.

Aggregate Root

Team (app/models/team.rb)

  • Owns billing, seats, and a directory of Communities. acts_as_paranoid.
  • belongs_to :billing_owner (User); has_one :subscription (TeamSubscription); has_many :memberships, :invites, :collective_goals, :leaderboard_entries.
  • MAX_SUB_COMMUNITIES = 10 (Phase 3 placeholder cap, not yet tied to a subscription plan tier).

Entities

EntityPurposeBase Class
TeamMembership (app/models/team_membership.rb)The seat — join record between a User and a Team. ROLES = %w[owner admin member external_coach]. Unique on (team_id, user_id).PublicRecord
TeamInvite (app/models/team_invite.rb)Link or email-targeted invite, optionally pre-selecting sub-communities. TEAM_ROLES = %w[admin member], STATUSES = %w[pending accepted revoked expired].PublicRecord
TeamSubscription (app/models/team_subscription.rb)Links a Team to a Plan and a Stripe subscription; tracks seats and lifecycle. STATUSES = %w[trialing active past_due grace canceled], TRIAL_DAYS = 14. Failed-payment state machine: past_due for 7 days → grace (read-only) for 7 more days → canceled.PublicRecord
CollectiveGoal (app/models/collective_goal.rb)A team- or sub-community-scoped shared goal, separate from a personal Goal. TARGET_METRICS = %w[check_ins milestones_completed members_active_days custom]. community_id nil = team-wide.PublicRecord
CollectiveGoalContribution (app/models/collective_goal_contribution.rb)Opt-in record linking a User to a CollectiveGoal via their personal goal. Unique on (collective_goal_id, user_id). Opt-out sets opted_out_at rather than destroying the row, preserving events_contributed history.ApplicationRecord — g16 exception, not exposed via public_id
CollectiveGoalContributionCredit (app/models/collective_goal_contribution_credit.rb)Append-only idempotency ledger claiming one events_contributed increment per (contribution, dedupe_key)"milestone:<public_id>" or "active_day:<YYYY-MM-DD>". Used by milestones_completed/members_active_days aggregation (OBJ-1834); check_ins doesn't need it. Unique on (collective_goal_contribution_id, dedupe_key).ApplicationRecord — g16 exception, not exposed via public_id; never soft-deleted
TeamLeaderboardEntry (app/models/team_leaderboard_entry.rb)Denormalized leaderboard row per (team, community, user, period). SCOPES = %w[team_wide sub_community], PERIODS = %w[weekly monthly all_time].ApplicationRecord — g16 exception, not exposed via public_id
TeamPulseResponse (app/models/team_pulse_response.rb)Quarterly internal telemetry survey response. Objectuve-internal only — "no team-admin read path exists or should ever be added for this data" (Data Boundary Contract, PRD §6). Unique on (team_id, user_id, period).ApplicationRecord — g16 exception, not exposed via public_id

Sub-communities are not a separate model. A "sub-community" is an ordinary Community record with belongs_to :team, optional: true (nil = free/public community, not part of a Team) — Community itself remains owned by the Social & Community context (context 2); Teams only adds interactions that create/join/leave/archive Community rows scoped to a team_id.

Application Services (Interactions)

23 files under app/interactions/teams/:

InteractionPurpose
Teams::StartTeamCheckoutOpens a Stripe Checkout session for a Teams plan (new team or an existing team re-subscribing, owner-only for the latter).
Teams::ProvisionTeamSubscriptionInvoked from Webhooks::StripeController on checkout.session.completed for a team checkout. Multi-write in a transaction: creates the Team + owner TeamMembership on first checkout (or reuses an existing team), then creates/updates TeamSubscription and stamps the pending PaymentRecord.
Teams::AdjustTeamSeatsOwner-only. Updates the Stripe subscription item quantity and local seat_count; blocks reducing below current member count.
Teams::TransferTeamBillingOwnershipOwner-only. Reassigns Team#billing_owner and swaps owner/admin TeamMembership roles so exactly one member holds owner.
Teams::CancelTeamSubscriptionOwner-only. Cancels at Stripe (cancel_at_period_end); soft-deletes the team immediately within the 7-day refund window, otherwise leaves it intact and notifies via Teams::TeamCanceledJob.
Teams::GrantBetaParticipantCompensationOps-triggered. One grant per team subscription: free during the beta window, then a locked 50% discount for 6 months post-GA.
Teams::CreateTeamInviteOwner/Admin-only. Creates a link (and optionally email-targeted) invite; does not send mail itself.
Teams::RevokeTeamInviteOwner/Admin-only. Revokes a pending invite so its code can no longer be accepted.
Teams::AcceptTeamInviteInvitee-only, looked up by invite code. Seat-cap check wrapped in a transaction with a row lock on the team to prevent over-seating from simultaneous accepts.
Teams::PromoteTeamMemberOwner/Admin-only. Changes a member's role; refuses to set role to owner (use TransferTeamBillingOwnership for that).
Teams::RemoveTeamMemberOwner/Admin-only. Clears sub-community memberships, anonymizes leaderboard entries, and soft-deletes the TeamMembership. The billing owner cannot be removed this way.
Teams::CreateSubCommunityAdmin-only. Creates a Community scoped to the team and seats its lead as admin CommunityMember; falls back to the "Other" GoalCategory.
Teams::JoinSubCommunitySelf-service join for an "open" sub-community. Rooms with join_policy: "request" currently refuse the join (no approval workflow exists yet).
Teams::LeaveSubCommunitySelf-service, idempotent leave. The team's default room cannot be left.
Teams::ArchiveSubCommunityAdmin-only. Starts a 30-day read-only archive window (stamps archived_at, schedules Teams::ArchiveSubCommunityJob). The default room can never be archived.
Teams::UnarchiveSubCommunityAdmin-only. Reverses an archive within the 30-day window by clearing archived_at.
Teams::SetDefaultSubCommunityAdmin-only. Reassigns which sub-community is the team's default landing room.
Teams::CreateCollectiveGoalOwner/Admin-only, requires an active team subscription.
Teams::OptIntoCollectiveGoalAny seated member. Links a personal Goal to a CollectiveGoal as a contribution; re-opt-in after opt-out reuses the same row.
Teams::OptOutOfCollectiveGoalSelf-service. Sets opted_out_at rather than destroying the row.
Teams::AcknowledgeCollectiveGoalPrivacyContractFirst-time-only server-side acknowledgment (UserAction-style "seen" state per docs/development/notification-surfaces.md); idempotent.
Teams::SetLeaderboardVisibilitySelf-service, member-level opt-out of the leaderboard.
Teams::SubmitTeamPulsePersists a quarterly pulse survey response. Internal telemetry — no admin read path.

GraphQL Mutations

All registered under app/graphql/mutations/teams/ (20 of the 23 interactions above are directly mutation-backed; Teams::ProvisionTeamSubscription and Teams::GrantBetaParticipantCompensation are invoked internally from a webhook / ops tooling rather than a GraphQL mutation).

Background Jobs

app/jobs/teams/: TeamLeaderboardUpsertJob, CollectiveGoalContributionUpsertJob (both enqueued from Goal Tracking — see 05 — Application Services), ArchiveSubCommunityJob, TeamThirtyDayCheckinJob (+ ThirtyDayCheckinEnqueueJob), SendWeeklyRecapJob (+ WeeklyRecapEnqueueJob), TeamWelcomeJob, TeamUpgradedJob, TeamDowngradedJob, TeamCanceledJob, PaymentFailedJob, TrialEndingJob, TrialExpiredJob.

Owned Tables

teams, team_memberships, team_invites, team_subscriptions, collective_goals, collective_goal_contributions, team_leaderboard_entries, team_pulse_responses


8. Feedback (Supporting Domain)

Purpose: User voice — a lightweight product feedback board where users post, comment on, and upvote feature requests, improvements, and bug reports. Gives the product team structured roadmap input directly from users.

Classified as a supporting domain — a source of product intelligence with its own aggregates and engagement mechanics, coupled only to User.

Aggregate Roots

FeedbackPost (app/models/feedback_post.rb)

  • The central entity. Owns comments and votes. Belongs to User.
  • Enforces: title (≤200), description (≤2000), category presence, status presence.
  • Categories: feature, improvement, bug, other.
  • Statuses: open, planned, in_progress, completed, declined.
  • Scopes: by_votes, by_newest, by_category(cat), by_status(stat), search(query), by_user(user), voted_by_user(user), shipped.
  • Uses counter caches (vote_count, comment_count) for performant ordering.

Entities

EntityPurpose
FeedbackCommentReply on a feedback post. Has is_official flag for staff replies. Soft-deleted.
FeedbackVoteOne per user per post (unique constraint). Powers the by_votes ordering and voted_by_user scope.

Value Object Candidates

  • FeedbackStatus — the status enum lifecycle (openplannedin_progresscompleted/declined) currently validated as a raw enum.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
Feedback::CreateFeedbackPostCreate a postNone
Feedback::CreateFeedbackCommentReply to a post (sets is_official when staff)Reads User role
Feedback::ToggleFeedbackVoteAdd/remove a vote, updates counter cacheNone
Feedback::UpdateFeedbackPostStatusAdmin updates status (e.g., mark planned, in_progress, completed)Admin-only; may trigger NotifyVotersJob

Background Jobs

JobTriggerPurpose
Feedback::NotifyVotersJobFrom UpdateFeedbackPostStatusNotify everyone who voted on a post when its status changes (e.g., item shipped)

Design Decision: Feedback Posts Are Not Moderated Content (Today)

FeedbackPost, FeedbackComment, and FeedbackVote do not currently participate in the Content Moderation cross-cutting concern. There is no polymorphic association from ContentFlag or ContentReport to feedback models, and none of the feedback models expose a moderation_status column. AiUsageEvent::FEATURES includes moderation as an AI feature, but that applies to social content, not feedback.

Rationale: Feedback is a low-volume product channel with direct staff review built into the admin workflow. Profanity or spam in feedback would be caught during admin status updates rather than through automated flagging.

Revisit trigger: If user-to-user replies on feedback posts become adversarial, or if feedback volume grows enough that staff can't review individually, introduce the same polymorphic ContentFlag wiring that Social uses.

Owned Tables

feedback_posts, feedback_comments, feedback_votes


9. Critical Path (Supporting Domain)

Purpose: Daily-puzzle mini-game that awards stim-XP and stim-streak progression. Players solve a shared daily puzzle (all users on the same UTC day receive the same server-generated seed); score is based on elapsed time. The stim economy is intentionally distinct from the main goal-XP/level system — it operates on its own stim_* fields on User.

Aggregate Root

CriticalPathPlay (app/models/critical_path_play.rb)

  • One record per user per puzzle date. Enforces one-completion-per-day via a unique index on (user_id, puzzle_date) WHERE deleted_at IS NULL.
  • Key fields: puzzle_date, elapsed_seconds, completed_at.
  • Key method: CriticalPathPlay.percentile_for(puzzle_date:, user_id:) — returns a PERCENT_RANK percentile (1–100) across all completions on a given date; returns nil if fewer than 10 plays exist.

Entities

EntityPurposeKey Fields
StimXpLedgerAppend-only XP delta record per play. Enforces idempotency via unique index on (critical_path_play_id, reason) WHERE critical_path_play_id IS NOT NULL AND deleted_at IS NULL.user_id, critical_path_play_id, delta, reason (enum: completion)

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
CriticalPath::RecordPlaySubmit today's elapsed seconds; creates CriticalPathPlay (idempotent on duplicate), publishes critical_path.stim_completed, returns percentile rankPublishes critical_path.stim_completedGamification (event-mediated; Gamification::AwardStimXp performs the stim-field write — see shared-kernel exception)
CriticalPath::FetchTodayReturn today's puzzle state: completed?, elapsed seconds, percentile, daily seedNone (read-only)
CriticalPath::FetchStimXpStatusReturn stim economy summary: total XP, current and longest stim streak, active theme, unlocked themes catalogReads UserDetailUser Identity
CriticalPath::FetchReminderPreferencesReturn user's Critical Path reminder notification preferencesReads UserDetailUser Identity
CriticalPath::SetActiveThemeSet the user's active puzzle theme (unlocked by stim-streak milestones)Writes to UserDetail.critical_path_active_themeUser Identity
CriticalPath::SetReminderPreferencesUpdate Critical Path reminder schedule (enabled, time_of_day) and optionally User timezoneWrites to UserDetail and conditionally User.timezoneUser Identity

Owned Tables

critical_path_plays, stim_xp_ledgers, plus four stim_* columns on users: stim_xp_total, stim_streak_current, stim_streak_longest, stim_last_played_on.

Design Decision: Stim Progression Fields on User

The users table holds stim_xp_total, stim_streak_current, stim_streak_longest, and stim_last_played_on. These logically belong to the Critical Path context but live on User for the same persistence-convenience reason as the Gamification xp/level fields — avoiding an extra join on every authenticated request that displays the user's stim state.

Cross-context coupling: these fields are written by Gamification::AwardStimXp, triggered by the critical_path.stim_completed domain event that CriticalPath::RecordPlay publishes — Critical Path no longer writes User directly. Decided 2026-08-09 (Option C, docs/architecture/proposals/critical-path-user-coupling.md): Option B (event-driven, no direct User write) was the target state, and it's now migrated per OBJ-2370.

Extraction trigger: If stim-progression rules gain their own aggregate (e.g., stim multipliers, seasonal puzzle bonuses, theme unlock trees with complex invariants), extract a UserStimProgression model that Critical Path owns, with User holding only a cached stim_xp_total for display.


10. Integrations & Connected Apps

Purpose: Connects a user's third-party activity providers (Strava, Chess.com) to Objectuve, ingests their activity, and — above a confidence threshold — automatically checks that activity in against a matching habit. This is a textbook anti-corruption layer context: each provider's own vocabulary and formats (Strava's sport_type, Chess.com's game-archive JSON) are translated at the boundary into Objectuve's internal activity_type taxonomy before anything reaches a Goal.

Classified as a supporting domain — it doesn't drive the product's core loop, but it feeds real check-ins into Goal Tracking from outside the app.

Aggregate Root

IntegrationConnection (app/models/integration_connection.rb)

  • A user's link to a connected provider. For OAuth providers (Strava) it holds encrypted access_token/refresh_token; for auth_type: 'none' providers (Chess.com) it holds external_username instead, since there's no OAuth callback to source an identifier from.
  • Owns habit_integration_mappings and integration_activities (dependent: :destroy).
  • STATUSES = %w[active paused error].

Entities

EntityPurposeKey Fields
IntegrationActivityA single fetched activity from a connected provider (a Strava run, a Chess.com game), optionally checked against a habitprovider_activity_id, activity_type, timestamp, idempotency_key (unique — connection_id:provider_activity_id:date, prevents double-checking the same provider activity)
HabitIntegrationMappingA confidence-scored provider-activity-type → habit (Goal) mapping, proposed or user-acceptedprovider_activity_type, confidence_score (0–1), auto_check_enabled

Reference Data

ModelPurpose
IntegrationProviderCatalog entry for a connectable service (Strava, Chess.com). AUTH_TYPES = %w[oauth api_key none].

External-System Integration Notes

Every provider implements the same abstract contract, IntegrationProvider::Adapter (rails_api/lib/integration_provider/adapter.rb): authorize_url, exchange_code, fetch_recent_activity, verify_signature — each raising NotImplementedError unless a provider overrides it.

  • Strava (IntegrationProvider::Strava) — OAuth + webhook. A real-time provider: activities arrive via webhook (Webhooks::StravaControllerStrava::IngestionJob), not polling, so #fetch_recent_activity is intentionally left unimplemented. Maps Strava's type/sport_type to Objectuve's activity_type via ACTIVITY_TYPE_MAP (e.g. Run/TrailRun/VirtualRunrun).
  • Chess.com (IntegrationProvider::Chess) — public REST, auth_type: 'none'. Polls the player's monthly game-archive endpoints for a date range and normalizes each game's time_class into an activity type prefixed chess_ (e.g. chess_blitz).
  • Confidence scoring: MapIncomingActivity (read-only, persists nothing) scores an IntegrationActivity against the connection owner's active, recurring goals using a rule table — category match (exact or partial) plus small bonuses for timestamp proximity, same-day check-in, and day-of-week history — and classifies the best match as auto (score ≥ AUTO_CHECK_THRESHOLD = 0.85), propose (≥ PROPOSAL_THRESHOLD = 0.50), or ignore.
  • Write path into Goal Tracking: AutoCheckIn is the only place an integration activity becomes a real check-in — it refuses anything below the auto-check threshold (surfaced instead as a proposal, never silently checked), then calls GoalTracking::CheckInHabit and creates an attribution GoalEvent (source: :integration, content "Checked in via <provider>"). ProcessIncomingActivity is the ingestion-triggered entry point that runs MapIncomingActivity and routes to either AutoCheckIn (auto band) or a published integrations.activity_proposed domain event (propose band).
  • Notifications: Integrations::Subscribers turns integrations.activity_checked_in and integrations.activity_proposed into the surfaced NewHabitToast notification — each event fires exactly once per fresh mapping, since AutoCheckIn's own idempotency guard and IntegrationActivity#idempotency_key's uniqueness already prevent re-firing on a redelivered or re-ingested activity.
  • Feature flag: connected_apps_enabled gates the connectedApps GraphQL query (Resolvers::IntegrationQueries) — raises a FORBIDDEN GraphQL::ExecutionError when disabled for the current user.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
AuthorizeConnectionExchange an OAuth callback code for provider tokens, persist an IntegrationConnectionReads User Identity
ConnectChessConnect/reconnect a Chess.com account by username (server-validated allowlist pattern)None
DisconnectConnectionRemove a connected provider (soft-delete); mappings/activities cascadeNone
PauseConnection / ResumeConnectionPause or resume sync for a connection without dropping tokens/mappingsNone
MapIncomingActivityScore an activity against the owner's habits and return the best match + confidence decision (read-only)Reads Goal Tracking (Goal, HabitCompletion)
ProcessIncomingActivityThe fresh-ingestion trigger: scores a new activity and routes to auto-check-in or a proposal eventCalls MapIncomingActivity, AutoCheckIn (context-internal)
AutoCheckInIdempotent wrapper that turns a confidence-scored activity into a real habit check-inCalls GoalTracking::CheckInHabit, writes GoalEventGoal Tracking
AcceptMapping / EditMapping / DismissMappingPersist, re-point, or remove a HabitIntegrationMappingReads Goal Tracking (Goal)
RerunMappingRe-score a connection's un-checked-in activities against the user's current habits (read-only, no persistence)Reads Goal Tracking (Goal)

Owned Tables

integration_connections, integration_activities, integration_providers, habit_integration_mappings


11. Personal Analytics

Purpose: A private, owner-scoped analytics dashboard — month-in-review hero, completion-by-category bars, an activity heatmap, streak history, and cumulative XP over time. Every interaction reads and computes on demand; nothing is persisted. Distinct from the operator-facing admin /analytics surface: this is the "see your own progress" view, scoped to context[:current_user] only, with no admin/other-user override.

Classified as a supporting domain, not a sub-context of Goal Tracking or Gamification — it reads across three contexts (Goal Tracking, Gamification, User Identity) to compute its series, and folding it under any one of them would hand that context ownership of data it doesn't produce.

Aggregate Roots / Owned Tables

None — Personal Analytics owns zero models and zero tables. It is a derived read-model (CQRS-style query context): every value it returns is computed at request time from data owned elsewhere — Goal Tracking (Goal, GoalEvent, HabitCompletion), Gamification (badge-unlock XP proxy), and User Identity (User#streak_activity_dates, #streak_length_on, #current_streak, MoodLog). Registered with models: [], the same explicit "this context owns no tables" convention the platform domain already uses.

Shared Support Modules

ModulePurpose
AnalyticsPeriodShared period windows for every series: month-to-date (hero), trailing 90 days (heatmap), trailing 26 weeks (streak/XP trend), with the current/incomplete week clamped to today so partial weeks aren't undercounted.
BadgeXpProxyNo per-event XP ledger exists in the database, so badge unlocks stand in as the XP signal across every Personal Analytics series — XP_PER_BADGE = 100, matching the frontend's "1 badge = 100 XP" model.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
CalculateMonthInReviewMonth-to-date hero: days active, best streak, badge-XP earned, goals completed, leading categoryReads Goal Tracking, User Identity, Gamification (via BadgeXpProxy); calls CalculateCompletionByCategory internally
CalculateCompletionByCategory% of each category's goals completed month-to-date, using the shared GoalProgressCalculator precedence; folds overflow past the top 5 into "Other"Reads Goal Tracking (Goal, GoalProgressCalculator)
CalculateActivityHeatmapOwner-scoped daily activity counts (habit check-ins, goal events, mood logs) over the trailing 90 daysReads Goal Tracking (HabitCompletion, GoalEvent), User Identity (MoodLog)
CalculateStreakHistoryOne point per week over the trailing 26 weeks, valued via User#streak_length_on so history matches the live streak's grace rules exactlyReads User Identity
CalculateXpOverTimeCumulative badge-XP-proxy total per week over the trailing 26 weeksReads Gamification (via BadgeXpProxy)

Feature Flag

personal_analytics_enabled gates all five GraphQL queries in Resolvers::PersonalAnalyticsQueries — fails closed (returns nil, not an error) when disabled for the current user, mirroring TeamsFeatureGate's nil convention.

Owned Tables

None — see Aggregate Roots / Owned Tables above.


12. Import/Export

Purpose: Lets a user re-import a goals/habit_completions CSV pair — in practice, their own prior GDPR export (Phase 1's ImportExport::CsvSchema is the single source of truth both Gdpr::ExportBuilder and this context validate against) — as an in-request staged preview the user must explicitly confirm before anything is written to goals/habit_completions.

Classified as a supporting domain. As of Phase 2 (v4.64, OBJ-3822) it covers upload → parse → validate → stage → preview only; committing a staged import into real Goal/HabitCompletion rows is Phase 3+ and not yet implemented — DataImport#status's committing/committed/failed/expired enum values exist today but nothing transitions into them yet.

Aggregate Root

DataImport (app/models/data_import.rb)

  • A user's staged CSV import. PublicRecord + acts_as_paranoid, belongs_to :user.
  • status enum (prefix: :status — a bare committed! would otherwise collide with ActiveRecord::Transactions's own transaction-commit callback): staged, committing, committed, failed, expired.
  • Holds the parsed result entirely in-database: staged_rows (jsonb, by file), row_errors / row_warnings (jsonb, by file), goal_count, completion_count, plus not-yet-wired commit-tracking columns (current_step, total_steps, created_count, skipped_count, failed_count, committed_at) reserved for Phase 3.
  • Named DataImport, not ImportExport::DataImport — deliberately outside the ImportExport module to avoid a Ruby class/module namespace collision with the module itself; ImportExport::CsvSchema and ImportExport::ParseCsv stay namespaced.
  • Never backs onto ActiveStorage or GCS. The uploaded CSV Tempfile is unwrapped via UploadIo.for, read, validated, and discarded — this model is the entire durable footprint of an import; there is no blob, upload record, or storage attachment anywhere in this context.

Domain Service

ImportExport::CsvSchema (app/services/import_export/csv_schema.rb, Phase 1)

  • Single source of truth for the importable/exportable CSV contract — required/optional columns, enum allow-lists (life_area, recurrence_type), and the recurrence_days |-delimited encoding. Both Gdpr::ExportBuilder (writes) and this context's ParseCsv (reads) validate against it, so the two can never drift on column shape independently.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
ImportExport::ParseCsvUnwraps a goals.csv + habit_completions.csv upload pair, enforces a 2 MB / 5,000-row cap per file (fail_with(code: :too_large)), validates every row against CsvSchema, and creates a DataImport(status: :staged) with per-row errors (by CSV line number, header row counted) and name-collision flags.Reads Goal Tracking (current_user.goals for collision detection)

Collision Detection

A staged goal row is flagged — never rejected — when its name case-insensitively matches an existing non-deleted goal already owned by the user. ParseCsv fetches current_user.goals.pluck(:name) once per parse into a memoized, downcased Set rather than querying per row, so a 5,000-row file costs one query, not up to 5,000. Surfaced to the client via Types::DataImportCollisionType under DataImportType#collisions.

GraphQL Surface

Mutations::DataImport::BeginImport (beginDataImport) → Types::DataImportType. graphql_name 'DataImport' is pinned explicitly on the type so the schema surface stays invariant under a future Ruby-side rename of the ImportExport module. A Sentry breadcrumb on the multipart boundary (category: 'graphql.upload') mirrors Mutations::UpdateUserPhoto's existing pattern, logging only presence flags and arg keys — never file content.

Rate Limiting

beginDataImport is throttled 5 requests/minute per IP (Rack::Attack, config/initializers/rack_attack.rb), keyed via GraphqlOperationInspector.field_name since every GraphQL operation shares the single /graphql path — the same field-name-keyed shape as the pre-existing guide-assistant-ip throttle.

Owned Tables

data_imports


Cross-Cutting Concerns

GDPR Compliance

GDPR data rights (export, deletion, rectification) span all contexts equally. GDPR is a cross-cutting concern with its own aggregate (GdprRequest) and interaction surface. See GDPR & Data Rights below for the full, reconciled section (aggregate fields, real deletion mechanics, and the interaction table) — this used to be two separate, contradicting sections; they've been merged into one.


Content Moderation

Content moderation spans Social & Community and Goal Tracking — any user-generated content (community posts, post comments, goal event comments) can be flagged or reported. It is not a standalone bounded context because it has no independent aggregate lifecycle; instead it attaches to content entities owned by other contexts via polymorphic associations.

Models:

ModelPurposeKey Fields
ContentFlagSystem-generated moderation marker on content. Polymorphic via flaggable.source (profanity_filter / ai_screen / user_report), severity (low / medium / high / critical), status (pending / approved / rejected), reviewer_id, reviewed_at
ContentReportUser-submitted report against content. One per user per content item (unique constraint).reporter_id, reason (spam / harassment / profanity / discouraging / other), status (pending / reviewed / dismissed), reviewer_id, reviewed_at

Domain Services:

ServicePurpose
ContentModerationServiceEntry point for automated screening. Called from interactions that create user content.
ContentModeration::ProfanityFilterRule-based profanity detection. Produces a ContentFlag with source: :profanity_filter if issues found.

Background Jobs:

JobTriggerPurpose
ScreenContentJobContentModerationService.screen (async)Run AI-based content screening on newly created content

Application Services (Interactions):

InteractionPurposeNotes
ReportContentUser reports a piece of content (post, comment). Creates both a ContentReport and a ContentFlag with source: :user_report.Allowed types: CommunityPost, PostComment, GoalEventComment
ReviewContentFlagAdmin approves or rejects a flag. Updates the flagged content's moderation_status.Admin-only
ContentModeration::BulkReviewContentFlagsAdmin bulk-approves/rejects up to MAX_BULK_SIZE = 50 content flags atomically, wrapping each per-flag update in Admin::Logged.Admin-only
ReviewContentReportAdmin reviews or dismisses a user report.Admin-only

Flaggable content types: CommunityPost, PostComment, GoalEventComment — all have a moderation_status column (0 = approved, 1 = pending_review, 2 = rejected).

Owned Tables: content_flags, content_reports

Extraction trigger: If moderation gains its own workflow engine (appeal process, escalation tiers, audit trail dashboard), consider promoting to a full bounded context.


Onboarding (Multi-Context Orchestration)

Purpose: First-run experience spanning sign-up, first goal creation, first check-in, and reward grants. Onboarding is not a standalone domain — it orchestrates interactions from User Identity (sign-up), Goal Tracking (goal creation), Gamification (reward unlocks), and AI Coaching (personalized prompts).

Interactions / Flows

FlowContexts InvolvedTriggersOutcome
OnboardingFlow::CompleteSignUpUser Identity, GamificationUser signs in for first timefirst_sign_in badge, initial streak XP via RecordSignIn
OnboardingFlow::FirstGoalCreatedGoal Tracking, GamificationAddGoal (goal_count == 1)create_first_goal badge, first category/type badge eligibility
OnboardingFlow::FirstCheckInCompletedGoal Tracking, GamificationCheckInHabit (first completion)GrantStarterFreeze (one free streak token awarded), habit_formed badge progression
User sees AI-generated milestonesAI Coaching, Goal TrackingGoal created, onboarding surfaceCoaching prompts embedded in goal detail page; optional AI refinement of goal description

These interactions are implemented across their respective contexts but coordinated by the frontend's onboarding surface (ionic_frontend/src/views/welcome/WelcomeLayout.vue, the onboarding-specific wrapper around the shared SlideWizardLayout.vue, routed at /welcome/:slideId via WelcomeView.vue). No separate Onboarding model or table exists.


GDPR & Data Rights (Cross-Cutting Concern)

Purpose: Fulfill data-subject rights obligations — export, deletion, and rectification — as mandated by GDPR, CCPA, and other privacy regulations. This is a cross-cutting concern because it reads from (and, for deletion, writes to) every bounded context to assemble exports and execute cascades. In-code comments tag this area # Context: Privacy & Compliance (GDPR).

Aggregate Root

GdprRequest (app/models/gdpr_request.rb)

  • Represents a single data-subject request. acts_as_paranoid (soft-deletable itself).
  • REQUEST_TYPES = %w[export deletion rectification] — there is no access type.
  • STATUSES = %w[received processing fulfilled failed] (default received) — not pending → approved → executing → completed, and not submitted/processing/completed/expired.
  • SLA is 72 hours, not 30 days: due_by is set to received_at + 72.hours on creation (set_received_and_due), matching the class's own "72-hour EU GDPR response window" comment. There is no 1-year expiry field.
  • Key fields: public_id, requestor_email, request_type, status, received_at, due_by, fulfilled_at, fulfilled_by_id, notes, submission_metadata (jsonb), export_file_url, export_expires_at.
  • Scopes: open_requests (received/processing), overdue (open_requests where due_by < Time.current).
  • belongs_to :fulfilled_by, class_name: 'User', optional: true.

There is no GdprExport model or gdpr_exports table. Export archives are tracked directly on GdprRequest via the export_file_url / export_expires_at columns — the archive is a GCS object, not a separate ActiveRecord entity.

Domain Service

Gdpr::ExportBuilder (app/services/gdpr/export_builder.rb)

  • Builds the export bundle content, invoked from Gdpr::BuildExportBundleJob.
  • Respects soft deletions and user privacy settings.

Supporting services: Gdpr::SignedUrlGenerator (app/services/gdpr/signed_url_generator.rb) and Gdpr::ExportDownloadToken (app/services/gdpr/export_download_token.rb) issue the time-limited download link for a fulfilled export.

Application Services (Interactions)

InteractionPurposeCross-Context Coupling
Gdpr::SubmitRequestPublic intake (no auth required) for any of the 3 request types. Validates email + request_type, creates GdprRequest(status: 'received'), enqueues Gdpr::SendAcknowledgmentJob (delivers GdprMailer.acknowledgment, starting the 72h SLA clock).Writes to GdprRequest.
Gdpr::CascadePreviewAdmin-only dry-run. Returns per-model counts matching the real deletion cascade split (see ExecuteDeletion below): user, goals, goal_events, mood_logs, user_actions, milestones, user_devices, community_memberships, community_posts, community_comments, goal_event_encouragements, ai_employee_memories, admin_actions. Read-only, query-at-render (counts reflect current state).Reads from Goal Tracking, Social, Gamification, User Identity, AI Coaching/Workforce, Admin.
Gdpr::ExecuteDeletionAdmin-triggered, step-up-gated. Burns the step-up token, resolves the GdprRequest, then delegates the actual erasure cascade to Gdpr::UserDataCascade inside its own transaction and finalizes the request. Not a pure soft-delete — see Design Decision below.Writes/hard-deletes across Goal Tracking, Social, Gamification, User Identity, AI (memories), Admin (scrubs, doesn't delete, AdminAction rows).
Gdpr::DeleteSelfServiceAccountSelf-service (OBJ-2183), no admin gate, no GdprRequest — an authenticated user deletes their own account (deleteOwnAccount mutation). Delegates the same erasure cascade to Gdpr::UserDataCascade; writes its own AdminAction (actor: the user) since there's no admin actor to attribute.Same cascade coupling as Gdpr::ExecuteDeletion, minus the GdprRequest/step-up machinery.
Gdpr::ExecuteExportAdmin-only (super_admin or support, enforced at the GraphQL layer). Transitions the request to processing, enqueues Gdpr::BuildExportBundleJob.Delegates the actual export build/upload to the job.
Gdpr::RequestSelfServiceExportSelf-service, no admin gate — a user requests their own export. 24-hour cooldown per email. Creates GdprRequest(request_type: 'export', status: 'received'), enqueues Gdpr::BuildExportBundleJob with a nil actor.Same downstream job as ExecuteExport.

Background Jobs

JobTriggerPurpose
Gdpr::SendAcknowledgmentJobFrom SubmitRequestDeliver GdprMailer.acknowledgment, starting the 72h SLA clock
Gdpr::BuildExportBundleJobFrom ExecuteExport / RequestSelfServiceExportBuild the ZIP via Gdpr::ExportBuilder, upload to a private GCS bucket, issue a 30-day download JWT (Gdpr::ExportDownloadToken), set status: 'fulfilled' + fulfilled_at + export_file_url on the request (or status: 'failed' + an AdminAction on error). Admin-triggered runs wrap the success path in Admin::Logged; self-service runs skip it (no admin actor to attribute).

Owned Tables

gdpr_requests only — plus soft-delete markers (deleted_at) and, for the hard-delete paths below, no markers at all, on the user-owned data it touches across other contexts' tables.

Design Decision: The Erasure Cascade Is a Shared Service, and a Mixed Hard/Soft Delete

The actual destructive cascade — the steps that erase a user's data — lives in Gdpr::UserDataCascade (app/services/gdpr/user_data_cascade.rb), not in either interaction that triggers it. Two callers share it: Gdpr::ExecuteDeletion (admin-triggered, step-up-gated) and Gdpr::DeleteSelfServiceAccount (self-service, OBJ-2183). Each opens its own ActiveRecord::Base.transaction and calls Gdpr::UserDataCascade.run!(user, email_hash); the service itself never opens or rescues a transaction, and raises on any failure so the caller's own rescue/Sentry/logging handles it. Admin-flow-only concerns — step-up token burn, GdprRequest resolution/finalization, and success/failure AdminAction audit rows — stay in Gdpr::ExecuteDeletion and are not part of the shared service. The cascade was extracted incidentally during OBJ-2183's merge-conflict resolution and generalized in PR #2150, after the two callers had independently hand-listed (and drifted on) the same cascade twice.

Gdpr::UserDataCascade does not uniformly soft-delete. Reading the service directly:

  • Hard-deleted (irrecoverable): integration connections, the user's goal tree, mood logs, UserAction records, user devices, community memberships, coach conversations, a set of leaf owned records (ally invites, checkin tokens, team memberships, etc.), feedback posts/comments, enneagram assessments, and AI employee memories — all via really_destroy! or delete_all.
  • Soft-deleted / scrubbed: authored community posts and post comments (.destroy — though PostComment is not itself paranoid, so this is a hard delete in practice despite the method name), goal-event encouragements (.destroy), and the User row itself (PII columns scrubbed via update_columns, then paranoid-destroyd).
  • Preserved, not deleted: AdminAction audit rows are kept for the platform's own audit trail but have their PII scrubbed; payment_records are retained under GDPR Art. 17(3)(b)'s tax/accounting exception.

So user data is irrecoverably destroyed for several tables — this is by design (an actual GDPR erasure obligation cannot be satisfied by a reversible soft-delete alone), not a bug. Any future doc or gate check should describe this as a mixed hard/soft cascade, not a pure soft-delete.

Extraction trigger: If GDPR workflows gain their own queue, SLA-breach alerting beyond the overdue scope, an audit dashboard, or legal hold workflows, consider promoting to a standalone bounded context. Today, it's a cross-cutting concern because it orchestrates interactions across other domains without owning its own domain lifecycle.


Contexts Not Separated (and Why)

Mood Logging

MoodLog is 8 lines with no business logic beyond validation. It belongs to User and optionally links to Goal. Until AI coaching integration deepens (e.g., mood-based goal recommendations, sentiment trends), it remains a sub-concern of User Identity.

Extraction trigger: When mood data drives product decisions (AI coaching adapts based on mood patterns, mood-based community matching, wellbeing dashboards).

Notifications & Feed Delivery

UserNotification and UserFeedItem are delivery mechanisms, not a domain. They're created by interactions across multiple contexts and consumed by the Social & Community feed. They belong to Social & Community as infrastructure entities.

Admin & Analytics

Administrative capabilities are cross-cutting rather than a bounded context. They span three kinds of surface:

  • Reporting / read-only aggregation: AdminStatsService, CalculateUserStatsService, and Admin::GenerateWordCloud project across all contexts and have no writes of their own.
  • RBAC and audit: AdminRole (roles: super_admin, admin, support, moderator; legacy User#admin boolean auto-synced on super_admin grant) and AdminAction (polymorphic audit log of every moderator/admin action with metadata JSONB). Neither inherits PublicRecord — these are internal-only.
  • Admin interactions: Admin::Logged (base/helper for wrapping admin actions with AdminAction audit entries), Admin::UpdateUserRoles, Admin::UserSearch, Admin::GenerateWordCloud, Admin::ComputeActivationFunnelWow (week-over-week activation funnel; internally calls Admin::ComputeOnboardingFunnel), Admin::ComputeOnboardingFunnel.

Because AdminRole/AdminAction only describe who may act and what was done across every other context — rather than owning their own domain lifecycle — Admin remains a cross-cutting service layer, not a bounded context.


Last updated: 2026-09-18 — documented AiRequest and the Async AI Delivery Path as a sub-context of AI Coaching (§5): entity/lifecycle, Ai::DispatchAiRequestJob/Ai::PurgeCompletedAiRequestsJob/Ai::ReportStrandedAiRequestsJob, retention, and the OBJ-3972 stranded-gauge instrumentation (OBJ-3982).

Previously: 2026-09-15 — added Import/Export (§12) as a new supporting bounded context (v4.64 Phase 2, OBJ-3822).

Loading…