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:
- Goal Tracking (Core Domain)
- Social & Community (Accountability)
- User Identity & Profile (Shared Kernel)
- Gamification & Progression (Supporting)
- AI Coaching (Supporting — per-user ephemeral prompts)
- AI Workforce (Platform Infrastructure — autonomous scheduled agents) (added since initial DDD pass)
- Billing (Supporting — Supporter/Teams payments via Stripe) (added since initial DDD pass)
- Feedback (Supporting — user voice and roadmap input) (added since initial DDD pass)
- Critical Path (Supporting — daily-puzzle mini-game producing stim-XP and stim-streak progression) (added since initial DDD pass)
- Integrations & Connected Apps (Supporting — anti-corruption layer for third-party activity providers) (added since initial DDD pass)
- Personal Analytics (Supporting — read-model dashboard over other contexts' data) (added since initial DDD pass)
- 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
| Entity | Purpose | Key Fields |
|---|---|---|
Milestone | Sub-goal linked to parent via parent_goal_id → public_id | Inherits from Goal |
HabitCompletion | One-per-day check-in record | goal_id, completed_date (unique pair), streak_freeze_used |
GoalEventEncouragement | "Like" on an event (soft-deleted for toggle) | goal_event_id, user_id (optional) |
GoalEventReaction | Emoji reaction on an event (16 allowed emojis). Soft-deleted for toggle. One per user per event (swappable). | goal_event_id, user_id, emoji |
GoalEventComment | Text reply on an event | goal_event_id, user_id, content |
GoalEncouragement | Goal-level encouragement (distinct from event-level). Optional comment text. | goal_id, user_id (optional), comment |
GoalMedia | Image attached to an event | goal_event_id, image_url |
GoalCollage | Visual progress collage | goal_id |
Reference Data
| Model | Purpose |
|---|---|
GoalType | Classification of approach (habit, milestone, quantity). Aliased as kind. |
GoalCategory | Broad 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:
- RecurrenceSchedule —
recurrence_type+recurrence_days+recurrence_interval. Encapsulates the "when is this habit due?" logic includingdue_today?andexpected_completions. - StreakState —
habit_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)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
AddGoal | Create a goal with type, category, recurrence | Triggers badge jobs → Gamification |
UpdateGoal | Edit goal properties, soft/hard delete | None |
AddGoalEvent | Log progress update | Triggers GenerateFollowFeedItemsJob → Social |
UpdateGoalEvent | Edit/delete progress entry | None |
CheckInHabit | One-tap daily habit completion | Calls GamificationService.award_xp → Gamification, triggers feed job → Social |
UseStreakFreeze | Apply freeze token to missed day | None |
CalculateGoalProgress | Group events by period for analytics | None (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:
pending→accepted|blocked. - Unique constraint on
(user_id, ally_id).
Entities
| Entity | Purpose |
|---|---|
CommunityMember | User membership with role (admin/moderator/member) and points |
CommunityGoal | Join table linking a Goal into a Community |
CommunityEvent | Scheduled event within a community |
EventAttendee | RSVP for a community event |
CommunityFollow | Notification subscription to a community |
CommunityFeedItem | Activity stream entry within a community |
CommunitySuggestion | User-submitted community recommendation |
CommunityMedia | Image on a community feed item |
PostComment | Reply to a community post |
PostReaction | Emoji/like reaction to a post |
UserGoalFollow | Subscription to another user's public goal |
UserFeedItem | Personal activity stream entry (badge earned, encouragement received, etc.) |
UserNotification | In-app notification with kind enum and JSONB details |
FeedKind | Reference/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.
- AllyStatus —
pending|accepted|blocked. Currently a raw string validated onUserAlly.
Application Services (Interactions)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
CreateCommunity | Create community with founder as admin | None |
JoinCommunity | Add user as member, create feed item | Triggers badge job → Gamification |
LeaveCommunity | Remove membership | None |
FollowCommunity | Subscribe to community notifications | None |
UnfollowCommunity | Unsubscribe | None |
CreateCommunityPost | Post in a community | None |
AddGoalToCommunity | Share a goal into a community | Reads from Goal Tracking |
AddCommunitySuggestion | Submit community recommendation | None |
ToggleFollowGoal | Follow/unfollow a public goal | Reads from Goal Tracking |
ToggleGoalEventEncouragement | Like/unlike a goal event | Reads from Goal Tracking, triggers notification |
ToggleGoalEventReaction | Toggle emoji reaction on a goal event | Reads from Goal Tracking, triggers notification and feed item |
AddGoalEventComment | Comment on a goal event | Reads from Goal Tracking, triggers notification |
AddPostComment | Comment on a community post | Triggers content screening via ContentModerationService |
BuildUnifiedFeed | Aggregate feed from allies, communities, own goals | Reads from Goal Tracking, User Identity |
SendPushNotification | Deliver push notification | Reads from User Identity (devices) |
AcknowledgeNotification | Mark a notification as read | None |
AcknowledgeAllNotifications | Batch mark-all-read, optional team_id scope | None |
DeleteNotification | Remove a notification | None |
BuildNotificationHistory | Paginated notification history + unread count, optional team scope | None |
Query Service: BuildUnifiedFeed
This is the most cross-cutting service in the system. It reads from four sources within a 14-day lookback:
- Ally activity —
GoalEvents from accepted allies' public goals (viaUserAlly) - Community posts —
CommunityPosts in communities the user belongs to - User feed items —
UserFeedItemrecords (notifications, badges) - 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
| Entity | Purpose |
|---|---|
UserAccount | Removed — OAuth is now managed entirely by Clerk. The clerk_user_id field on User links to the Clerk-managed identity. |
UserDetail | Extended profile metadata (JSONB) |
UserPhoto | Profile image |
UserDevice | Push notification token (platform, token) |
UserSignIn | Sign-in aggregation record |
UserSignInHistory | Per-day sign-in log |
CheckinToken | Device-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. |
MagicCode | One-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
| Service | Purpose |
|---|---|
ClerkJwtVerifier | Verify RS256 JWTs from Clerk via JWKS endpoint. Anti-corruption layer for external auth. |
ClerkUserSync | Find-or-create local User from Clerk profile on first sign-in. |
Application Services (Interactions)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
ClerkUserSync | Sync Clerk user to local DB | None (pure identity) |
UpdateUser | Edit profile (name, username) | Syncs to Clerk (external) |
UpdateUserPhoto | Profile image upload | None |
StoreUserDetails | Extended profile metadata | None |
StoreDeviceToken | Register push notification token | None |
RecordSignIn | Track sign-in, update streak | Calls GamificationService.award_xp → Gamification |
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_typeandwingin 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)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
SubmitEnneagramAssessment | Score 40 forced-choice answers, compute dominant type / wing / tritype, persist EnneagramAssessment | None (writes to User Identity's own aggregate) |
DismissEnneagramCard | Mark UserDetail.enneagram_card_dismissed = true to suppress the assessment prompt | Writes 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 beUserIdentity::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)
| Interaction | Purpose | Cross-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, mon | None (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 activity | Reads 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
actionenum value, anacknowledgedflag, and belongs to a user. - Generated once per user per action type. Triggers a
UserFeedItemand notification.
Badge Types (UserAction enum)
| Badge | Trigger |
|---|---|
first_sign_in | User's first login |
create_first_goal | First goal created |
complete_first_goal | First goal completed |
complete_fifth_goal | 5th goal completed |
complete_tenth_goal | 10th goal completed |
create_second_type | Goals in 2 different types |
create_second_category | Goals in 2 different categories |
join_first_community | First community joined |
consistency_is_key | Sustained streak |
habit_former | Habit completion milestone |
repair_streak | Rebuilt streak after gap (via RepairStreak interaction) |
award_partner_bonus | Partner-bonus XP milestone (via AwardPartnerBonus subscriber) |
dedicated, supporter, super_fan | Engagement tiers |
visionary, achiever, explorer, innovator, planner, executor | Archetype badges |
early_bird | Early 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
| Job | Trigger | Purpose |
|---|---|---|
CreateFirstGoalActionJob | AddGoal (first goal) | Award create_first_goal badge |
CreateSecondCategoryActionJob | AddGoal (second category) | Award create_second_category badge |
CreateSecondTypeActionJob | AddGoal (second type) | Award create_second_type badge |
JoinFirstCommunityActionJob | JoinCommunity (first) | Award join_first_community badge |
CreateLevelUpNotificationJob | GamificationService.check_for_level_up | Send level-up notification |
ProcessHabitStreaksJob | Crono (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 |
CheckBadgesJob | gamification.streak_advanced event subscriber | Evaluate and award streak milestone badges (7, 14, 30, 60, 90 day milestones) |
StreakBrokenJob | gamification.streak_broken event subscriber | Award resilience/comeback badges when user breaks a streak |
CreateFeedbackBadgeJob | Feedback::CreateFeedbackPost (direct enqueue) | Award voice_heard / community_voice badge when user submits feedback |
FoundingMemberBadgeJob | Eligible user check (early sign-ups) | Award founding member status badges |
Application Services (Interactions)
| Interaction | Purpose |
|---|---|
AcknowledgeAction | Mark a badge as seen |
UpdateShowcasedAchievements | Customize profile badge showcase |
RepairStreak | Rebuild 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 |
AwardPartnerBonus | Award XP bonus when both allied users check in on the same day; called from Gamification::Subscribers after goal_tracking.habit_checked_in event fires |
GrantStarterFreeze | Issue 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
| Model | Purpose | Key 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. |
AiUsageEvent | Anonymous, 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 goalgenerate_goal_draft— draft a full goal (name/description/category/kind) from a free-text idearefine_description— improve goal description textget_advice— contextual coaching advicegenerate_insight— page-scoped insights (dashboard, goals, achievements, communities)generate_synopsis— periodic coach synopsis for a usergenerate_at_risk_nudge— nudge for a goal at risk of lapsinggenerate_welcome_back— re-engagement message after a lapsegenerate_compassionate_break— message after a broken streakgenerate_check_in_prompt— personalized daily check-in prompt
Background Job
| Job | Schedule | Purpose |
|---|---|---|
AiCoaching::GenerateDailyInsightPackJob | Crono (daily 06:00) | Pre-generate the day's coach insights per user |
AiCoaching::GenerateAtRiskCoachMessageJob | Crono (hourly) | Detect at-risk goals and dispatch a coach nudge (dedup via CoachTriggerDispatch) |
AiCoaching::GenerateFollowUpCoachMessageJob | Event-triggered (goal_tracking.habit_checked_in via Ai::Subscribers) | Generate a completion follow-up coach message after a habit check-in |
AiCoaching::RecordCoachInteractionJob | Enqueued inline from Ai::ServiceClient | Persist the append-only CoachInteraction audit record; fail-closed with Sidekiq retry |
AiCoaching::GenerateAiCheckInPromptsJob | Crono (daily 10:00), gated to AI_CHECK_IN_INTERNAL_ALLOWLIST while ramping | Generate 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::AiBudgetAlertJob | Crono (hourly) | Alert when AI spend approaches budget thresholds |
AiMetrics::RecordAiUsageEventJob | Enqueued inline from Ai::ServiceClient | Persist the append-only AiUsageEvent cost/token record; fail-closed with Sidekiq retry |
Ai::DispatchAiRequestJob | Enqueued 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::PurgeCompletedAiRequestsJob | Crono (daily 04:15 UTC) | Hard-deletes succeeded/failed AiRequest rows past the 7-day retention window; never touches pending rows |
Ai::ReportStrandedAiRequestsJob | Crono (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 goalgenerate_goal_draft— returns a drafted goal from a free-text idearefine_description— returns improved goal description textget_advice— returns coaching responseget_insight— returns page-contextual insight (resolves viaAi::CoachService#generate_insight)ensure_todays_coach_message— returns (or lazily generates) today's coach messageupdate_coaching_preferences— updates a user's coaching preferences
Dependencies
- Reads
Goaldata (name, description, events, milestones, completion rate) - Reads
Userdata (level, streak, goal count, coaching preferences, Enneagram profile viaEnneagramAssessment) - 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 AiRequest — succeeded 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: pending → Ai::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):
- GraphQL-layer limit — 20 requests/min per caller (user or IP), surfaced as a graceful
RATE_LIMITEDGraphQL error (Types::QueryType#ask_guide_question). - Rack-attack IP throttle — 30 requests/min per IP, deliberately coarser than the GraphQL-layer limit (
config/initializers/rack_attack.rb,guide-assistant-ipthrottle). 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. Ai::BudgetGuardmonthly cost ceiling —Settings.ai.docs_assistant_monthly_budget_cents(the generic, opt-in mechanism shared with every AI feature). Once month-to-date spend for thedocs_assistantfeature reaches the ceiling,Ai::ServiceClient.chatreturnsnilrather 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_leveltransitions (shadow→semi_autonomous→autonomous) via thepromotion_requirements_metvalidation; per-month budget cap (monthly_budget_cents); role uniqueness; customer-support role never eligible for promotion aboveshadow. - 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:
queued→running→succeeded | failed | cancelled. - Owns
AiArtifactrecords produced during the run. - Enforces: valid status,
triggered_bypresence (scheduler, manual, webhook).
Entities
| Entity | Purpose |
|---|---|
AiArtifact | Durable output of a run awaiting operator review. Kinds: draft_post, code_patch, email_template, report, recommendation. Approval statuses: pending, approved, rejected, auto_rejected. |
AiEmployeeMemory | Structured JSONB memory injected into the agent system prompt at run time. Unique (ai_employee_id, memory_key). Capped at ~2,000 tokens when assembled. |
AiUsageEvent | Append-only cost and token record per LLM call. Shared with AI Coaching — see section 5 above. |
Value Object Candidates
- AutonomyLevel —
shadow|semi_autonomous|autonomous. Currently a validated string onAiEmployeealongside promotion rules that already encapsulate transition invariants. - MonthlyBudget —
monthly_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)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
AiWorkforce::TriggerRun | Queue a run for an employee (guards against duplicate queued/running runs) | None |
AiWorkforce::UpdateEmployee | Update employee configuration (name, schedule, budget, autonomy with validation) | None |
AiWorkforce::ApproveArtifact | Operator approves an artifact, enqueues delivery | Enqueues DeliverArtifactJob (context-internal) |
AiWorkforce::RejectArtifact | Operator rejects an artifact (manual or auto via brand-voice post-filter) | None |
AiWorkforce::ProcessWebhookResult | Apply Agent Runner HMAC-signed webhook result to an AiRun (status, artifacts, cost) | Webhook from Agent Runner (external) |
Background Jobs
| Job | Trigger | Purpose |
|---|---|---|
AiWorkforce::ScheduleRunsJob | Crono (every 5 minutes) | Enqueue a run for every due_for_run employee |
AiWorkforce::ExecuteRunJob | From TriggerRun | POST to Agent Runner; halt after setting status to running (completion comes via webhook) |
AiWorkforce::DeliverArtifactJob | From ApproveArtifact | Ship approved artifact (GitHub PR via Octokit, Slack post, Mailtrap sandbox, docs commit) |
AiWorkforce::BudgetAlertJob | Triggered on high-cost runs | Post Slack alert, deduplicated via Rails.cache |
AiWorkforce::TimeoutStaleRunsJob | Crono (periodic) | Bulk-update stuck runs to failed via update_all |
Domain Services
| Service | Purpose |
|---|---|
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::OidcAuth | Fetches OIDC identity tokens for authenticating to the Agent Runner service in GCP. |
Webhooks::AiWorkforceController | Inbound 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 toPlan. - 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,
providerin%w[stripe revenuecat],statusin%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)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
Billing::CreateCheckout | Create a Stripe checkout session for a Plan, returning the redirect URL | Calls StripeService (external, via service wrapper) |
Billing::ProcessStripeWebhook | Idempotent processor for Stripe webhook events; records stripe_event_id on PaymentRecord and publishes billing.payment_processed | Publishes domain event — Billing::Subscribers mutates user plan + payment record |
Billing::ProcessRevenueCatWebhook | Idempotent 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_processed | Publishes 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
| Job | Trigger | Purpose |
|---|---|---|
Billing::ExpireSupportersJob | Crono (daily) | Revoke supporter entitlements for users whose subscriptions have lapsed |
External Seams
Stripe — StripeService (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
| Entity | Purpose | Base 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/:
| Interaction | Purpose |
|---|---|
Teams::StartTeamCheckout | Opens a Stripe Checkout session for a Teams plan (new team or an existing team re-subscribing, owner-only for the latter). |
Teams::ProvisionTeamSubscription | Invoked 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::AdjustTeamSeats | Owner-only. Updates the Stripe subscription item quantity and local seat_count; blocks reducing below current member count. |
Teams::TransferTeamBillingOwnership | Owner-only. Reassigns Team#billing_owner and swaps owner/admin TeamMembership roles so exactly one member holds owner. |
Teams::CancelTeamSubscription | Owner-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::GrantBetaParticipantCompensation | Ops-triggered. One grant per team subscription: free during the beta window, then a locked 50% discount for 6 months post-GA. |
Teams::CreateTeamInvite | Owner/Admin-only. Creates a link (and optionally email-targeted) invite; does not send mail itself. |
Teams::RevokeTeamInvite | Owner/Admin-only. Revokes a pending invite so its code can no longer be accepted. |
Teams::AcceptTeamInvite | Invitee-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::PromoteTeamMember | Owner/Admin-only. Changes a member's role; refuses to set role to owner (use TransferTeamBillingOwnership for that). |
Teams::RemoveTeamMember | Owner/Admin-only. Clears sub-community memberships, anonymizes leaderboard entries, and soft-deletes the TeamMembership. The billing owner cannot be removed this way. |
Teams::CreateSubCommunity | Admin-only. Creates a Community scoped to the team and seats its lead as admin CommunityMember; falls back to the "Other" GoalCategory. |
Teams::JoinSubCommunity | Self-service join for an "open" sub-community. Rooms with join_policy: "request" currently refuse the join (no approval workflow exists yet). |
Teams::LeaveSubCommunity | Self-service, idempotent leave. The team's default room cannot be left. |
Teams::ArchiveSubCommunity | Admin-only. Starts a 30-day read-only archive window (stamps archived_at, schedules Teams::ArchiveSubCommunityJob). The default room can never be archived. |
Teams::UnarchiveSubCommunity | Admin-only. Reverses an archive within the 30-day window by clearing archived_at. |
Teams::SetDefaultSubCommunity | Admin-only. Reassigns which sub-community is the team's default landing room. |
Teams::CreateCollectiveGoal | Owner/Admin-only, requires an active team subscription. |
Teams::OptIntoCollectiveGoal | Any seated member. Links a personal Goal to a CollectiveGoal as a contribution; re-opt-in after opt-out reuses the same row. |
Teams::OptOutOfCollectiveGoal | Self-service. Sets opted_out_at rather than destroying the row. |
Teams::AcknowledgeCollectiveGoalPrivacyContract | First-time-only server-side acknowledgment (UserAction-style "seen" state per docs/development/notification-surfaces.md); idempotent. |
Teams::SetLeaderboardVisibility | Self-service, member-level opt-out of the leaderboard. |
Teams::SubmitTeamPulse | Persists 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
| Entity | Purpose |
|---|---|
FeedbackComment | Reply on a feedback post. Has is_official flag for staff replies. Soft-deleted. |
FeedbackVote | One per user per post (unique constraint). Powers the by_votes ordering and voted_by_user scope. |
Value Object Candidates
- FeedbackStatus — the status enum lifecycle (
open→planned→in_progress→completed/declined) currently validated as a raw enum.
Application Services (Interactions)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
Feedback::CreateFeedbackPost | Create a post | None |
Feedback::CreateFeedbackComment | Reply to a post (sets is_official when staff) | Reads User role |
Feedback::ToggleFeedbackVote | Add/remove a vote, updates counter cache | None |
Feedback::UpdateFeedbackPostStatus | Admin updates status (e.g., mark planned, in_progress, completed) | Admin-only; may trigger NotifyVotersJob |
Background Jobs
| Job | Trigger | Purpose |
|---|---|---|
Feedback::NotifyVotersJob | From UpdateFeedbackPostStatus | Notify 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; returnsnilif fewer than 10 plays exist.
Entities
| Entity | Purpose | Key Fields |
|---|---|---|
StimXpLedger | Append-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)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
CriticalPath::RecordPlay | Submit today's elapsed seconds; creates CriticalPathPlay (idempotent on duplicate), publishes critical_path.stim_completed, returns percentile rank | Publishes critical_path.stim_completed → Gamification (event-mediated; Gamification::AwardStimXp performs the stim-field write — see shared-kernel exception) |
CriticalPath::FetchToday | Return today's puzzle state: completed?, elapsed seconds, percentile, daily seed | None (read-only) |
CriticalPath::FetchStimXpStatus | Return stim economy summary: total XP, current and longest stim streak, active theme, unlocked themes catalog | Reads UserDetail → User Identity |
CriticalPath::FetchReminderPreferences | Return user's Critical Path reminder notification preferences | Reads UserDetail → User Identity |
CriticalPath::SetActiveTheme | Set the user's active puzzle theme (unlocked by stim-streak milestones) | Writes to UserDetail.critical_path_active_theme → User Identity |
CriticalPath::SetReminderPreferences | Update Critical Path reminder schedule (enabled, time_of_day) and optionally User timezone | Writes to UserDetail and conditionally User.timezone → User 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; forauth_type: 'none'providers (Chess.com) it holdsexternal_usernameinstead, since there's no OAuth callback to source an identifier from. - Owns
habit_integration_mappingsandintegration_activities(dependent: :destroy). STATUSES = %w[active paused error].
Entities
| Entity | Purpose | Key Fields |
|---|---|---|
IntegrationActivity | A single fetched activity from a connected provider (a Strava run, a Chess.com game), optionally checked against a habit | provider_activity_id, activity_type, timestamp, idempotency_key (unique — connection_id:provider_activity_id:date, prevents double-checking the same provider activity) |
HabitIntegrationMapping | A confidence-scored provider-activity-type → habit (Goal) mapping, proposed or user-accepted | provider_activity_type, confidence_score (0–1), auto_check_enabled |
Reference Data
| Model | Purpose |
|---|---|
IntegrationProvider | Catalog 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::StravaController→Strava::IngestionJob), not polling, so#fetch_recent_activityis intentionally left unimplemented. Maps Strava'stype/sport_typeto Objectuve'sactivity_typeviaACTIVITY_TYPE_MAP(e.g.Run/TrailRun/VirtualRun→run). - 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'stime_classinto an activity type prefixedchess_(e.g.chess_blitz). - Confidence scoring:
MapIncomingActivity(read-only, persists nothing) scores anIntegrationActivityagainst 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 asauto(score ≥AUTO_CHECK_THRESHOLD = 0.85),propose(≥PROPOSAL_THRESHOLD = 0.50), orignore. - Write path into Goal Tracking:
AutoCheckInis 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 callsGoalTracking::CheckInHabitand creates an attributionGoalEvent(source: :integration, content"Checked in via <provider>").ProcessIncomingActivityis the ingestion-triggered entry point that runsMapIncomingActivityand routes to eitherAutoCheckIn(auto band) or a publishedintegrations.activity_proposeddomain event (propose band). - Notifications:
Integrations::Subscribersturnsintegrations.activity_checked_inandintegrations.activity_proposedinto the surfacedNewHabitToastnotification — each event fires exactly once per fresh mapping, sinceAutoCheckIn's own idempotency guard andIntegrationActivity#idempotency_key's uniqueness already prevent re-firing on a redelivered or re-ingested activity. - Feature flag:
connected_apps_enabledgates theconnectedAppsGraphQL query (Resolvers::IntegrationQueries) — raises aFORBIDDENGraphQL::ExecutionErrorwhen disabled for the current user.
Application Services (Interactions)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
AuthorizeConnection | Exchange an OAuth callback code for provider tokens, persist an IntegrationConnection | Reads User Identity |
ConnectChess | Connect/reconnect a Chess.com account by username (server-validated allowlist pattern) | None |
DisconnectConnection | Remove a connected provider (soft-delete); mappings/activities cascade | None |
PauseConnection / ResumeConnection | Pause or resume sync for a connection without dropping tokens/mappings | None |
MapIncomingActivity | Score an activity against the owner's habits and return the best match + confidence decision (read-only) | Reads Goal Tracking (Goal, HabitCompletion) |
ProcessIncomingActivity | The fresh-ingestion trigger: scores a new activity and routes to auto-check-in or a proposal event | Calls MapIncomingActivity, AutoCheckIn (context-internal) |
AutoCheckIn | Idempotent wrapper that turns a confidence-scored activity into a real habit check-in | Calls GoalTracking::CheckInHabit, writes GoalEvent → Goal Tracking |
AcceptMapping / EditMapping / DismissMapping | Persist, re-point, or remove a HabitIntegrationMapping | Reads Goal Tracking (Goal) |
RerunMapping | Re-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
| Module | Purpose |
|---|---|
AnalyticsPeriod | Shared 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. |
BadgeXpProxy | No 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)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
CalculateMonthInReview | Month-to-date hero: days active, best streak, badge-XP earned, goals completed, leading category | Reads 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) |
CalculateActivityHeatmap | Owner-scoped daily activity counts (habit check-ins, goal events, mood logs) over the trailing 90 days | Reads Goal Tracking (HabitCompletion, GoalEvent), User Identity (MoodLog) |
CalculateStreakHistory | One point per week over the trailing 26 weeks, valued via User#streak_length_on so history matches the live streak's grace rules exactly | Reads User Identity |
CalculateXpOverTime | Cumulative badge-XP-proxy total per week over the trailing 26 weeks | Reads 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. statusenum (prefix: :status— a barecommitted!would otherwise collide withActiveRecord::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, notImportExport::DataImport— deliberately outside theImportExportmodule to avoid a Ruby class/module namespace collision with the module itself;ImportExport::CsvSchemaandImportExport::ParseCsvstay namespaced. - Never backs onto ActiveStorage or GCS. The uploaded CSV
Tempfileis unwrapped viaUploadIo.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 therecurrence_days|-delimited encoding. BothGdpr::ExportBuilder(writes) and this context'sParseCsv(reads) validate against it, so the two can never drift on column shape independently.
Application Services (Interactions)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
ImportExport::ParseCsv | Unwraps 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:
| Model | Purpose | Key Fields |
|---|---|---|
ContentFlag | System-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 |
ContentReport | User-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:
| Service | Purpose |
|---|---|
ContentModerationService | Entry point for automated screening. Called from interactions that create user content. |
ContentModeration::ProfanityFilter | Rule-based profanity detection. Produces a ContentFlag with source: :profanity_filter if issues found. |
Background Jobs:
| Job | Trigger | Purpose |
|---|---|---|
ScreenContentJob | ContentModerationService.screen (async) | Run AI-based content screening on newly created content |
Application Services (Interactions):
| Interaction | Purpose | Notes |
|---|---|---|
ReportContent | User reports a piece of content (post, comment). Creates both a ContentReport and a ContentFlag with source: :user_report. | Allowed types: CommunityPost, PostComment, GoalEventComment |
ReviewContentFlag | Admin approves or rejects a flag. Updates the flagged content's moderation_status. | Admin-only |
ContentModeration::BulkReviewContentFlags | Admin bulk-approves/rejects up to MAX_BULK_SIZE = 50 content flags atomically, wrapping each per-flag update in Admin::Logged. | Admin-only |
ReviewContentReport | Admin 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
| Flow | Contexts Involved | Triggers | Outcome |
|---|---|---|---|
OnboardingFlow::CompleteSignUp | User Identity, Gamification | User signs in for first time | first_sign_in badge, initial streak XP via RecordSignIn |
OnboardingFlow::FirstGoalCreated | Goal Tracking, Gamification | AddGoal (goal_count == 1) | create_first_goal badge, first category/type badge eligibility |
OnboardingFlow::FirstCheckInCompleted | Goal Tracking, Gamification | CheckInHabit (first completion) | GrantStarterFreeze (one free streak token awarded), habit_formed badge progression |
| User sees AI-generated milestones | AI Coaching, Goal Tracking | Goal created, onboarding surface | Coaching 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 noaccesstype.STATUSES = %w[received processing fulfilled failed](defaultreceived) — notpending → approved → executing → completed, and notsubmitted/processing/completed/expired.- SLA is 72 hours, not 30 days:
due_byis set toreceived_at + 72.hourson 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_requestswheredue_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)
| Interaction | Purpose | Cross-Context Coupling |
|---|---|---|
Gdpr::SubmitRequest | Public 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::CascadePreview | Admin-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::ExecuteDeletion | Admin-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::DeleteSelfServiceAccount | Self-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::ExecuteExport | Admin-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::RequestSelfServiceExport | Self-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
| Job | Trigger | Purpose |
|---|---|---|
Gdpr::SendAcknowledgmentJob | From SubmitRequest | Deliver GdprMailer.acknowledgment, starting the 72h SLA clock |
Gdpr::BuildExportBundleJob | From ExecuteExport / RequestSelfServiceExport | Build 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,
UserActionrecords, 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 viareally_destroy!ordelete_all. - Soft-deleted / scrubbed: authored community posts and post comments (
.destroy— thoughPostCommentis not itself paranoid, so this is a hard delete in practice despite the method name), goal-event encouragements (.destroy), and theUserrow itself (PII columns scrubbed viaupdate_columns, then paranoid-destroyd). - Preserved, not deleted:
AdminActionaudit rows are kept for the platform's own audit trail but have their PII scrubbed;payment_recordsare 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, andAdmin::GenerateWordCloudproject across all contexts and have no writes of their own. - RBAC and audit:
AdminRole(roles:super_admin,admin,support,moderator; legacyUser#adminboolean auto-synced onsuper_admingrant) andAdminAction(polymorphic audit log of every moderator/admin action withmetadataJSONB). Neither inheritsPublicRecord— 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 callsAdmin::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).