Skip to content

Objectuve Data Models

This document details the data models used in the Objectuve Rails API (rails_api/app/models). It explains the purpose of key models, their relationships, and the overall schema architecture.

Overview

The application is built around Users who set Goals and participate in Communities. The core design emphasizes accountability through Feeds, Encouragements, and Comments.

Most primary models inherit from PublicRecord (which inherits from ApplicationRecord), ensuring they have a secure, unique public_id for external references (APIs, URLs).

Key Models

1. User (User)

The central entity of the application.

  • Inherits from: PublicRecord
  • Key Associations:
    • Has many Goals.
    • Has many UserActions (gamification/tracking user behavior).
    • Has many Communitys through CommunityMember.
    • Has a UserFeedItem feed for their personal activity stream.
    • Has one UserDetail (extended profile fields and coaching preferences — see below).
  • Key Fields:
    • timezone (string, default 'UTC') — IANA timezone string (e.g., 'America/Chicago'). Populated from the frontend via Intl.DateTimeFormat().resolvedOptions().timeZone on each sign-in. Used to evaluate streak boundaries and daily activity windows in the user's local timezone. Falls back to UTC (with a Sentry breadcrumb) if the IANA string is unrecognized via the User#timezone_or_default helper.
    • current_streak (integer) — The longest consecutive run of days on which the user performed any activity (habit check-in, goal event, or mood log), evaluated at the user's local midnight. Re-calculated daily by User#update_streak!. Forward-only migration: historical streaks are not re-attributed to the user's local timezone (see docs/features/gamification-engine.md for details).
    • stim_xp_total (integer, default 0) — Lifetime total Stim XP earned. Incremented by entries in the stim_xp_ledger table (see section 5d below). Used in theme unlock thresholds (CriticalPath::ThemeCatalog::Theme#unlock_threshold is compared against stim_streak_longest, not this field).
    • stim_streak_current (integer, default 0) — Current consecutive-day streak for Critical Path puzzle completion. Stale if the user has not played since yesterday. Reset when the streak breaks.
    • stim_streak_longest (integer, default 0) — All-time longest streak for Critical Path puzzle completion. Used to determine which visual themes are unlocked; compared against CriticalPath::ThemeCatalog::Theme#unlock_threshold (each entry in the THEMES array defined in rails_api/app/lib/critical_path/theme_catalog.rb).
    • stim_last_played_on (date, nullable) — Date (in user's local timezone) when the user last completed a Critical Path puzzle. Used to evaluate streak staleness (current day vs. yesterday).
    • go_it_alone (boolean, default false, null: false) — Opts the user out of ally/community/coach-social prompts. Checked by Social::FindSuggestedAllies and CommunityQueries#recommended_communities, gated on FeatureFlagService.enabled?('privacy_controls_v1', user: <the user being suppressed>) — inert when the flag is off for that user. Behind flag privacy_controls_v1.
    • private_mode (boolean, default false, null: false, indexed) — Hides the user from search_users (username search) results. Resolvers::UserQueries#search_users gates the exclusion on the candidate's own flag state, not the searcher's, so visibility can't be dodged just because the searcher's rollout cohort hasn't flipped yet; Admin::UserSearch (admin search) is unaffected. Behind flag privacy_controls_v1.
    • Both fields are set via the self-only updatePrivacySettings mutation (UserIdentity::UpdatePrivacySettings), which itself fails closed — raises FORBIDDEN when privacy_controls_v1 is off for the acting user.
  • Key predicates:
    • User#required_checkins_complete_today? — Returns true when the user has logged every habit check-in expected today, or when no habit goals are expected today (trivially satisfied). Scoped to active, non-completed habit goals (completed: false, recurrence_type IS NOT NULL). Timezone-aware via the per-goal expected_today? and checked_in_today? predicates — no separate timezone calculation at the aggregate layer. Exposed on the GraphQL surface as requiredCheckinsCompleteToday: Boolean! on Types::UserType. Foundation for the v1.23 Phase 115/116 Coach trigger redesign.
  • Authentication: Managed entirely by Clerk. The clerk_user_id field links the local User record to the Clerk-managed identity. OAuth providers (Google, etc.) are configured in Clerk, not in the application database.

1a. User Detail — coaching preferences (UserDetail)

Extended user profile fields stored in a JSONB data column. Populated by the Meet Coach wizard (see Meet Coach) and the onboarding flow.

  • Inherits from: PublicRecord (with acts_as_paranoid)
  • Belongs to: User
  • Storage: All coaching preferences live in user_details.data (JSONB) and are exposed as ActiveRecord attributes via store_accessor.
  • Coaching preferences fields (Phase 0, April 2026 onward):
FieldTypeNotes
coach_personastringOne of captain, spark, mirror, rival, sage, analyst.
coach_tone_brevityinteger 0..4X axis on the Tone Pad — Brief (0) ↔ Detailed (4). Stored as string in JSONB; coerced via .to_i on read.
coach_tone_warmthinteger 0..4Y axis on the Tone Pad — Demanding (0) ↔ Gentle (4). Stored as string in JSONB; coerced via .to_i on read.
coach_depthinteger 1..5Response depth axis. Default: 3.
coach_focus_primarystringOne of streaks, milestones, reflection, accountability.
coach_focus_secondarystring | nullSame enum, nullable.
coach_rhythmstringOne of morning, evening, bookends, when_needed. Replaces coach_frequency.
coach_configured_atISO 8601 datetimeSet on first save. Drives coachingPreferences.configured on the GraphQL surface.
critical_path_active_themestring | nullThe active visual theme key for the Critical Path puzzle experience. Stored via store_accessor. Nullable; defaults to 'default' on the GraphQL surface when absent. Must be a key from the theme catalog, validated at the interaction level by CriticalPath::SetActiveTheme.
weekly_digest_enabledbooleanWhether the user receives weekly digest emails. Defaults to true when absent.
weekly_digest_delivery_daystringDay of week to deliver the digest. Valid values: sun, mon, sat only (per the VALID_DAYS constant). Defaults to 'sun' when absent. No DB-level validation; guarded at interaction level by WeeklyDigest::SetWeeklyDigestPreferences.
last_digest_sent_atISO 8601 datetime | nullWritten by Social::WeeklyDigestSendJob after successful email delivery; acts as the idempotency guard for the weekly digest send loop.
  • Defaults (returned by User#coaching_preferences when fields are absent): persona: 'spark', tone_brevity: 2, tone_warmth: 2, depth: 3, focus_primary: 'streaks', focus_secondary: nil, rhythm: 'when_needed', configured: false.
  • Validation: enforced at the interaction level by UserIdentity::UpdateCoachingPreferences (rails_api/app/interactions/user_identity/update_coaching_preferences.rb). Invalid values fail with :invalid_input.
  • GraphQL surface: Types::CoachingPreferencesType exposes the same fields plus configured: Boolean. Mutation: Mutations::UpdateCoachingPreferences.
  • Side effect on first save: UserAction.generate(user_id:, action: :configure_coach) fires once when coach_configured_at transitions from blank.

Coaching Preferences Migration (April 2026)

Migration 20260428000001_extend_coaching_preferences replaced the single-axis coach_tone (1–5 scale) with a two-axis tone model (coach_tone_brevity and coach_tone_warmth, each 0–4), and renamed coach_frequency to coach_rhythm with updated enum values. Existing rows are backfilled in the up step:

Tone mapping (single 1–5 scale → two axes 0–4):

Legacy coach_toneNew coach_tone_brevityNew coach_tone_warmth
114
223
322
431
540

The warmth axis is mapped inversely: high legacy tone (demanding) maps to low warmth (0), and low tone (gentle) maps to high warmth (4).

Rhythm mapping (legacy coach_frequency → new coach_rhythm):

Legacy coach_frequencyNew coach_rhythm
stuckwhen_needed
milestoneswhen_needed
minimalmorning
dailymorning
activebookends

down performs a partial reversal: when_needed → stuck, morning → daily, bookends → active. Records that were originally milestones are indistinguishable from stuck after the up migration; both revert to stuck on rollback.

Migration 20260429000122_add_life_area_to_goals adds the life_area string column to the goals table. Set nullable; validated via the LIFE_AREAS constant on the model.

2. Goal (Goal)

Represents a user's objective.

  • Inherits from: PublicRecord
  • Key Associations:
    • Belongs to a User.
    • Belongs to a GoalType (e.g., specific predefined types) and GoalCategory (broad categories).
    • Has many GoalEvents (updates, milestones, completion).
    • Has many Milestones (sub-goals).
    • Can be shared to Communitys.
  • Key Fields:
    • life_area (string, nullable) — Optional categorization of the goal's life domain. One of: career, wealth, health, personal, relationship, family, social, spiritual, vision, other. Set at creation only; not editable in Phase 0.
    • from_template_id (string, nullable, indexed) — The public_id of the GoalTemplate this goal was created from (if any). Stored as a string to avoid FK constraint coupling; matches the parent_goal_id convention. Nullable; pre-v1.21 goals stay null and render without the from-template badge.
    • identity_prompt (string, nullable, max 240 chars, v3.12) — Optional free-text identity framing set at goal creation (e.g. "A consistent runner who shows up even on the hard days"). Personalizes Coach messaging when present; never required, no backfill for pre-v3.12 goals.
    • past_attempt_context (string, nullable, max 200 chars, v3.12) — Optional free-text context on a prior attempt at this goal, set at goal creation. Same optionality/personalization contract as identity_prompt.
    • completion_reflection (string, nullable, max 200 chars, v3.12) — Optional free-text reflection captured when the goal is marked complete (via GoalCompletionSheet.vue). Threads into Ai::CoachService#generate_welcome_back's streak-lapse pathway when the user later returns from a lapse. All three v3.12 fields are the only Goal columns where updateGoal distinguishes an explicit null (clears the value) from an omitted argument (leaves it untouched) — see Nullable Field: Clear vs. Omit Gotcha. Full personalization-pipeline detail: docs/features/goals.md § Coach Personalization.
  • Key Logic:
    • Tracks completion status and target dates.
    • Automatically generates "Goal created" and "Goal completed" events.
    • Habit mode: Goals with recurrence_type (daily/weekly/custom_days/interval) support one-tap check-in, per-habit streaks, and earned freeze tokens (7-day streak = 1 freeze).
    • Habit-specific fields: recurrence_type, recurrence_days (JSON array), recurrence_interval, duration_minutes (nullable, validated > 0), habit_streak, longest_habit_streak, streak_freezes_available, streak_freezes_used (integer, default 0 — lifetime count of freezes spent; subtracted from earned freezes to compute streak_freezes_available), starter_freezes_granted (integer, default 0 — one-time freeze grant set on habit creation by GoalTracking::AddGoal), streak_repair_eligible_until (datetime, nullable — deadline for the paid streak-repair window after a streak breaks; checked and cleared by Gamification::RepairStreak), streak_repaired_count (integer, default 0 — lifetime count of paid streak repairs, incremented by Gamification::RepairStreak), app_link (string, nullable, max 2048 chars — external app URL opened on check-in for one-click redirect to another app, e.g. duolingo://; scheme must be https or a custom app scheme not on the AppLinkValidatable::UNSAFE_APP_LINK_SCHEMES deny-set, which blocks http, ftp, javascript, data, file, vbscript, blob, about, chrome, intent, content, ms-appx, and itms-apps; updateGoal treats an explicit null as a clear, same CLEARABLE_NULLABLE_FIELDS pattern as identity_prompt/past_attempt_context/completion_reflection — see Nullable Field: Clear vs. Omit Gotcha).
    • Has many HabitCompletions (habit check-in records).
    • Goal#expected_today? — Returns true for active (non-completed) habit goals whose recurrence pattern matches today, regardless of whether a check-in has already been logged. Evaluated in user.timezone_or_default. All four recurrence types are covered: daily (always true), weekly/custom_days (today's day-of-week in recurrence_days), interval (days since last check-in ≥ recurrence_interval; true if never checked in). Returns false for non-habit goals and completed habit goals. Foundation for User#required_checkins_complete_today? and the v1.23 Coach trigger redesign.
    • Goal#due_today? — Composes the above as: return false if checked_in_today?; expected_today?. Returns false the moment a same-day check-in is recorded. This is the predicate consumed by the GraphQL dueToday field and the Coach prompt context builder (AiCoaching::GenerateAiCheckInPromptsJob#build_user_context).

2b. Habit Completion (HabitCompletion)

Records a single habit check-in for a given date.

  • Inherits from: ApplicationRecord
  • Key Associations:
    • Belongs to a Goal (habit-type goals only).
  • Key Fields:
    • completed_date — the date this check-in covers (in the user's local timezone)
    • streak_freeze_used — boolean, true if a freeze was used instead of a real check-in
    • note (text, nullable) — Optional one-sentence micro-journal note for this check-in. Max 280 characters (model validation), stripped of leading/trailing whitespace before save (blank input is stored as nil, never ""). No default, no backfill. Behind flag micro_journal_enabled — the mutation ignores incoming note when the flag is off for the user. Read access is scoped server-side to the completion's owner (HabitCompletionType#note returns nil unless context[:current_user].id == object.goal.user_id), so a note never leaks via public/community/ally goal reads. Read by the Coach through the existing BuildCoachContext recent-reflections signal — no new read path.
  • Scopes: in_current_period(goal), freezes_used
  • Usage: Created by the CheckInHabit interaction on one-tap check-in. Used by User#update_streak! (unified activity calculation) and legacy ProcessHabitStreaksJob routines to evaluate streak continuity. The date is stored in the user's local timezone for consistency across all activity types (events, mood logs, and habit completions).

2c. Goal Template (GoalTemplate)

A curator-authored goal template for quick-start goal creation.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp).
  • Key Associations:
    • Belongs to a GoalCategory (optional, for categorization).
    • No direct "has many Goals" association — Goal#from_template association does the reverse lookup via public_id.
  • Key Fields:
    • name (string, not null) — Template title (e.g., "Run Your First 5K").
    • description (text, not null) — Longer description of the template (e.g., "8-week structured training program to run a 5K without stopping").
    • theme (string, indexed) — One of: fitness, learning, financial, habit_wellness, career, productivity, relationships. Used for picker UI grouping.
    • estimated_duration_days (integer, not null) — Estimated time to complete the goal (e.g., 90). Used to compute target_date on form prefill.
    • image_url (string, nullable) — Bespoke template hero image URL (nullable; UI falls through to category-default stock photo if null).
    • display_order (integer, default 0) — Sort order within the theme grouping (curator-maintained).
    • milestones (JSONB, default []) — Array of { name: String, order: Int, days_offset_from_start: Int } objects. Read-only in v1; no per-milestone editing.
    • deleted_at (datetime, nullable) — Soft-deletion timestamp via acts_as_paranoid.
  • Key Logic:
    • Seeded once in rails_api/db/seeds/goal_templates.rb with 28 curator-authored templates (4 per theme × 7 themes) (v1.21 Phase 85).
    • Accessible via unauthenticated goalTemplates GraphQL query.
    • GoalTemplate.ordered_for_picker scope returns templates ordered by (theme, display_order) for the picker UI.
    • JSONB milestones validated by custom milestones_valid_shape validator (array of objects, each with required name/order/days_offset_from_start fields).
  • Usage: Queried by TemplatePickerModal.vue to render browsable templates. Selected template's public_id and milestones prefill the goal form via GoalCreate.vue handleTemplateSelect(). On addGoal mutation success, the template public_id is stored in the new goal's from_template_id field.

2d. Coach Trigger Dispatch (CoachTriggerDispatch)

Durable per-goal-per-period dedup record for Coach's proactive triggers (Phase 117).

  • Inherits from: ApplicationRecord — internal bookkeeping only, no public_id, never exposed via GraphQL.
  • Key Associations:
    • Belongs to a User.
    • Belongs to a Goal.
  • Key Fields:
    • trigger_kind (string, not null) — e.g. 'at_risk_goal'.
    • period_key (string, not null) — stable identifier for the cadence period the dispatch covers (see Goal#period_key / AtRiskCadence).
    • sent_at (datetime, not null).
  • Constraints: unique index on (goal_id, trigger_kind, period_key) — the DB-level guarantee that a given trigger fires at most once per goal per period, surviving a cache flush (a cache-only guard can't hold for weekly/interval periods).
  • Usage: written by Ai::EnsureAtRiskGoalCoachMessage after a successful at-risk nudge dispatch; checked before dispatch to skip an already-covered period.

3. Goal Event (GoalEvent)

Updates or progress logs associated with a goal.

  • Purpose: Acts as the immutable history of a goal. Every significant update is an event.
  • Inherits from: PublicRecord
  • Associations:
    • Belongs to Goal.
    • Has many GoalEventEncouragements (likes/support).
    • Has many GoalEventComments.
    • Can have attached Media (photos/videos).
  • Note on habit check-ins (Phase 59+): As of Phase 59, routine habit check-ins are no longer persisted as GoalEvent records. The CheckInHabit interaction creates a HabitCompletion record instead, which is the canonical source of truth for "did the user check in today?" Historical "Checked in!" event rows (created prior to Phase 59) remain in the database but are hidden from the UI via the Goal#displayable_events scope. This change reduces timeline noise and surfaces meaningful user-authored updates and milestones instead. Non-habit goals and user-created updates (via the "+ Update" button) continue to generate GoalEvent records normally.

4. Community (Community)

A group of users gathering around shared interests.

  • Inherits from: PublicRecord
  • Associations:
    • Belongs to GoalCategory.
    • Has many Users (members).
    • Has many Goals (shared goals).
    • Has many CommunityChallenges.
    • Has a CommunityFeedItem feed.

4a. Community Challenge (CommunityChallenge)

A time-boxed challenge that community members can join to compete and earn community-specific badges.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp).
  • Associations:
    • Belongs to Community.
    • Belongs to creator (User) — the community member who created the challenge.
    • Belongs to target_goal_type (GoalType, optional) — if set, only goal events of this type count toward the challenge.
    • Has many ChallengeParticipants.
    • Has many participants (User) through ChallengeParticipants.
  • Key Fields:
    • name (string, max 100, not null) — Challenge title (e.g., "7 Days of Code").
    • description (text, nullable) — Optional longer description of the challenge (max 1000 chars).
    • start_date (date, not null) — Challenge start date.
    • end_date (date, not null) — Challenge end date (must be ≥ start_date).
    • target_goal_count (integer, default 1, not null) — Number of qualifying goal events a participant must log to complete the challenge.
    • badge_name (string, max 50, not null) — Badge name awarded to completers (e.g., "January Challenge Master"). Note: Badge metadata is stored as denormalized columns on this model, not as a separate Badge model (deferred per Orion Assumption 1 — PRD §5d references a badge_id FK that was not implemented).
    • badge_icon (string, max 10, not null) — Badge icon identifier (emoji or short icon key) for displaying the reward.
    • completion_processed_at (datetime, nullable) — Timestamp when finalization was processed for this challenge. Set by ProcessChallengeCompletionsJob after challenge end date. Used to prevent duplicate badge awards.
  • Key Logic:
    • Challenges are created by community members (creator_id) and appear pinned to the community space.
    • Status is derived at runtime: upcoming (start_date > today), active (start_date ≤ today ≤ end_date), or completed (end_date < today).
    • During the active phase, member progress is tracked via ChallengeParticipant records.
    • On completion of the challenge period, participants who meet the criteria receive the badge and a celebration modal.

4b. Challenge Participant (ChallengeParticipant)

Join table tracking a user's participation and progress in a community challenge.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp). A partial unique index on [community_challenge_id, user_id] (WHERE deleted_at IS NULL) ensures a user can only have one active participation record per challenge.
  • Associations:
    • Belongs to User.
    • Belongs to CommunityChallenge.
  • Key Fields:
    • progress_count (integer, default 0, not null) — Number of qualifying goal events logged toward the challenge target.
    • completed_at (datetime, nullable) — Set when the participant reaches target_goal_count. Used by the completed? predicate and completed scope.
    • notified_at (datetime, nullable) — Timestamp of the durable badge-push idempotency marker; distinct from completed_at, which is owned by live progress tracking. Set by Social::FinalizeCommunityChallenge immediately after a successful badge push, so a rolled-back-then-retried finalize pushes each qualifier at most once.
  • Key Logic:
    • Created when a user taps "Join Challenge" on the community page.
    • The progress_count is incremented automatically by Social::IncrementChallengeProgress, called as a best-effort hook from GoalTracking::AddGoalEvent right after the goal event is created — matching, in-window challenge participants are incremented for any goal event (a hook failure is caught, logged, and reported to Sentry, but does not roll back the goal event).
    • Used to calculate leaderboard rankings during the active challenge phase.

5. Mood Log (MoodLog)

Records a user's self-reported emotional state at a point in time.

  • Inherits from: PublicRecord
  • Key Associations:
    • Belongs to a User.
    • Optionally belongs to a Goal (allows mood to be correlated with specific goal progress).
  • Key Fields:
    • mood — validated string enum: amazing, happy, calm, meh, tired, low
    • note — optional free-text journal entry (up to 300 characters in the UI)
  • Usage: Populated via the Mood Check-In modal. Used to track emotional wellbeing alongside goal progress.

5b. Enneagram Assessment (EnneagramAssessment)

Records a completed Enneagram personality assessment result for a user.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp)
  • Associations:
    • Belongs to a User
  • Key Fields:
    • scores (JSONB, required) — Normalized type scores, 0–100 scale. Keys are string integers "1" through "9" (not type_1 format). Example: { "1" => 100, "2" => 78, "3" => 78, ..., "9" => 0 }. Frontend EnneagramResultCard uses String(n) to look up the correct key.
    • dominant_type (integer 1–9, required) — The highest-scoring type. Deterministic tie-break: lowest type number wins.
    • wing (integer 1–9, required) — Adjacent type with the higher normalized score. Validated to be a neighbor of dominant_type.
    • tritype (string e.g. "583", required) — Three-character string: one dominant type per Enneagram center (body 8/9/1, heart 2/3/4, head 5/6/7), ordered by score descending. Validated to have exactly one digit from each center.
    • completed_at (datetime, required) — Timestamp when the assessment was finished.
    • deleted_at (datetime, nullable) — Soft-deletion timestamp via acts_as_paranoid.
  • Key Logic:
    • Each user can have multiple assessments (retakes create new rows, not updates).
    • EnneagramAssessment.normalize_scores(raw_counts) converts question pair tallies to 0–100 scale with string keys.
    • EnneagramAssessment.compute_dominant_type(scores) determines the highest-scoring type (deterministic on ties).
    • EnneagramAssessment.compute_wing(scores) returns the adjacent neighbor with the higher score.
    • EnneagramAssessment.compute_tritype(scores) returns a string of three types, one per center, ordered by descending score.
  • Usage: Coach system prompt builder injects the latest assessment result (if present) as a "User personality profile" block. See docs/features/coach.md for user-facing intent.

5c. Critical Path Play (CriticalPathPlay)

Records a user's completed puzzle solve for the Critical Path feature.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp). A partial unique index on [user_id, puzzle_date] (WHERE deleted_at IS NULL) ensures a user can only have one recorded solve per puzzle date; soft-deletion allows re-solving on the same date if the record is restored.
  • Associations:
    • Belongs to a User.
  • Key Fields:
    • puzzle_date (date, not null) — The puzzle date this solve covers.
    • elapsed_seconds (integer, not null) — Time taken to complete the puzzle, in seconds. Constrained to 1–86,400 (1 second to 24 hours via check constraint).
    • completed_at (datetime, not null) — Timestamp when the puzzle was marked complete.
    • public_id (string, unique) — Public identifier for external reference.
    • deleted_at (datetime, nullable) — Soft-deletion timestamp via acts_as_paranoid.
  • Constraints:
    • Check constraint: elapsed_seconds BETWEEN 1 AND 86400
    • Partial unique index on [user_id, puzzle_date] where deleted_at IS NULL (one solve per user per day)
    • Unique index on public_id
  • Key Logic:
    • Created when a user completes the daily Critical Path puzzle.
    • The percentile_for(puzzle_date:, user_id:) class method returns the PERCENT_RANK percentile (1–100) for a user's solve time relative to others on that date. Returns nil if fewer than 10 plays exist for the date (insufficient sample for ranking).

5d. Stim XP Ledger (StimXpLedger)

Records individual Stim XP award events, maintaining an immutable audit trail of how a user's total Stim XP was earned.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp).
  • Associations:
    • Belongs to a User.
    • Belongs to a CriticalPathPlay (optional FK — nil if the award is unrelated to a puzzle solve).
  • Key Fields:
    • user_id (bigint, not null) — Foreign key to the user being awarded XP.
    • delta (integer, not null) — Amount of XP awarded. Constrained to be positive (> 0 via check constraint).
    • reason (integer, not null) — Enum column (stored as integer) indicating the source of the award. Currently defined values: completion (0). Indexed for filtering.
    • critical_path_play_id (bigint, nullable) — Foreign key to the associated CriticalPathPlay record. nil if the award is not tied to a specific puzzle solve (e.g., streak milestone bonuses).
    • public_id (string, unique) — Public identifier for external reference.
    • deleted_at (datetime, nullable) — Soft-deletion timestamp via acts_as_paranoid.
  • Constraints:
    • Check constraint: delta > 0 — XP awards must be positive.
    • Unique index on public_id.
    • Indexes on user_id, reason, critical_path_play_id, and deleted_at for efficient filtering and cascade operations.
  • Key Logic:
    • An immutable, append-only ledger. Rows are never updated after creation.
    • User's stim_xp_total is denormalized on the User aggregate and kept in sync via the CriticalPath::AwardStimXp interaction (called after every XP-generating event).
    • Supports forensic auditing and streak comeback analysis.

6. User Action (UserAction)

Tracks system-defined user achievements or behaviors for gamification (e.g., "created first goal").

  • Inherits from: ApplicationRecord (Internal use mostly)
  • Usage: Used to award badges or trigger specific flows.

7. User Ally (UserAlly)

Represents a bidirectional ally connection between two users. Also carries accountability partner state.

  • Inherits from: PublicRecord
  • Key Associations:
    • Belongs to a User (the initiator).
    • Belongs to an ally (User).
  • Key Fields:
    • status"pending", "accepted", or "blocked".
    • accountability_partner (boolean, default false) — whether this ally pair has an active partnership.
    • accountability_partner_since (datetime) — when the partnership was established.
    • accountability_partner_requested (boolean, default false) — whether a partner request is pending.
    • accountability_partner_requested_by_id (bigint) — user ID of the requester.
    • mutual_streak_count (integer, default 0) — current mutual check-in streak.
    • longest_mutual_streak (integer, default 0) — all-time longest mutual streak.
    • partner_ended_at (datetime) — when the last partnership ended.
    • last_partner_bonus_date (date, nullable) — date of the last co-op XP partner bonus award. Used for idempotency — prevents double-awarding on the same calendar day. Set by Gamification::AwardPartnerBonus.
  • Key Scopes:
    • accepted — allies with status: 'accepted'.
    • with_active_partnership — accepted allies with accountability_partner: true.
    • with_pending_partner_request — accepted allies with a pending request.
    • find_for_users(user_a, user_b) — finds the accepted record in either direction.
  • Usage: Powers the ally list, accountability partner lifecycle (request/accept/decline/end), mutual streaks, and the partner dashboard widget.

7a. Ally Invite (AllyInvite)

A time-limited, shareable token that allows one user to invite another to become an accountability partner via a deep link. Once accepted, the invite creates or upgrades the underlying UserAlly relationship.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp).
  • Key Associations:
    • Belongs to an inviter (User).
  • Key Fields:
    • token (string, unique) — 43-character URL-safe base64 token (generated via SecureRandom.urlsafe_base64(32)). Embedded in the deep link: /invite/{token}.
    • status (string) — one of: "pending", "accepted", "expired", "revoked". Default: "pending".
    • expires_at (datetime) — 7 days from creation (INVITE_EXPIRY_DAYS = 7).
    • created_at (datetime) — timestamp of invite creation.
  • Constants:
    • INVITE_EXPIRY_DAYS = 7 — invites are valid for 7 days, then transition to "expired" status.
    • MAX_ACTIVE_INVITES = 3 — users may have at most 3 pending invites at a time.
    • RATE_LIMIT_WINDOW = 1.hour — rate-limiting window for invite creation.
    • MAX_CREATES_PER_WINDOW = 10 — max 10 invites per hour per user.
    • STATUSES = %w[pending accepted expired revoked] — valid status values.
  • Key Scopes:
    • active — invites with status: 'pending' and expires_at > Time.current.
  • Key Methods:
    • expired? — returns true if expires_at is in the past (invitation link no longer valid).
    • self.rate_limited?(inviter) — returns true if the user has created ≥10 invites in the past hour.
  • Key Logic:
    • Tokens are generated before validation (before_validation :generate_token, on: :create), ensuring uniqueness at the model level.
    • Expiry is set on create (before_validation :set_expiry, on: :create), defaulting to 7 days from creation.
    • Rate limiting is enforced at the interaction level by Social::CreateAllyInvite before creating a new record.
    • When accepted via acceptAllyInvite mutation, the status transitions to "accepted" and the underlying UserAlly relationship (or upgrade if existing) is created.
    • Revocation via revokeAllyInvite mutation sets status: 'revoked' and prevents further acceptance.
  • Usage: Invites are generated by users on the ally discovery page or dashboard partner widget (when no partner exists). A user can share the invite link with others. Recipients can open the link on mobile (deep link) or web, view the inviter's name and photo via the unauthenticated allyInvitePreview query, and accept (creating the partnership) or decline (silently, leaving the invite unaccepted).

7b. Partner Nudge (PartnerNudge)

Rate-limit ledger for Social::SendPartnerNudge — one row per nudge sent. Not a PublicRecord: ephemeral internal state, never exposed via GraphQL. Added to make the "one nudge per partnership per direction per day" rule representable at all — UserAlly's last_nudge_sent_at/last_nudge_sent_by_id columns are a single slot per pair, shared by both directions, so two users alternating nudges (A→B, B→A, A→B) could bypass the daily limit with no concurrency required (OBJ-2343).

  • Inherits from: ApplicationRecord (not PublicRecord — no public_id, not API-exposed)
  • Soft Deletion: None — plain hard rows.
  • Key Associations:
    • Belongs to a UserAlly (user_ally_id) — the partnership pair.
    • Belongs to a sender (User, via sender_id) — who sent the nudge.
  • Key Fields:
    • user_ally_id (bigint, not null) — the partnership this nudge belongs to.
    • sender_id (bigint, not null) — foreign key to users; the sender_id in the key is what makes "per direction" representable.
    • sent_on (date, not null) — the calendar day the nudge was sent.
  • Constraints:
    • Composite unique index on (user_ally_id, sender_id, sent_on) (idx_partner_nudges_ally_sender_day_unique) — unconditional, not partial: partner_nudges has no deleted_at (see Soft Deletion above), so there is no WHERE clause to scope it. The DB-level enforcement of the daily-per-direction rule. Two rows with the same partnership, sender, and day violate the index (ActiveRecord::RecordNotUnique); the same partnership and day but a different sender_id (the other direction) is allowed.
    • Indexes on sender_id and user_ally_id individually, for GDPR cascade and lookup queries.
  • Key Logic:
    • PartnerNudge.sent_today?(user_ally:, sender:) — the single owning method for the rule, called by both Social::SendPartnerNudge's guard and the nudge_sent_today GraphQL field (user_queries.rb), so the two can never disagree.
    • Social::SendPartnerNudge creates a row inside the same transaction as its UserAlly update, and rescues ActiveRecord::RecordNotUnique from the unique index as the authoritative belt-and-braces guard against a race between two concurrent requests from the same sender — the same shape as StimXpLedger/CriticalPath::AwardStimXp (see 5d. Stim XP Ledger).
  • GDPR: hard-deleted on erasure (PartnerNudge.where(sender_id: user.id).delete_all) — rate-limit state, not audit history. See Data Deletion § Hard-deleted.

Teams (Billing) Models

Schema for v4.0 Teams V1 — a paid, private, multi-community workspace for groups. Phase 1 shipped the schema only (models, no API wiring); Phase 2 wired the full GraphQL surface and made TeamAccessPolicy the live authorization gate for every Teams read and mutation. See docs/product/phase-7-teams.md and .planning/milestones/v4.0-teams-v1-ROADMAP.md.

GraphQL surface (Phase 2):

  • Queries: myTeam, teamSettings(teamId:), teamHome(teamId:), teamInvitePreview(code:), teamPulseEligibility(teamId:) — all fail closed (return null, not an error) when the caller isn't a member (PRIVACY-2). teamSettings/teamHome back the TeamSettingsView (N7) and TeamHomeView (N13) aggregate reads.
  • Mutations: startTeamCheckout, adjustTeamSeats, transferTeamBillingOwnership, cancelTeamSubscription, createTeamInvite, revokeTeamInvite, acceptTeamInvite, promoteTeamMember, removeTeamMember, joinSubCommunity, leaveSubCommunity, createSubCommunity, archiveSubCommunity, unarchiveSubCommunity, setDefaultSubCommunity, submitTeamPulse — registered in app/graphql/types/mutation_type.rb, resolved by app/interactions/teams/*.rb.

Team (Team)

Aggregate root — owns billing, seats, and a directory of Community records.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp).
  • Key Associations:
    • Belongs to a billing_owner (User).
    • Has one subscription (TeamSubscription, dependent: :destroy).
    • Has many memberships (TeamMembership, dependent: :destroy); has many members (User) through memberships.
    • Has many communities (dependent: :nullify — a Community survives its Team being destroyed, falling back to a free/public community).
    • Has many invites (TeamInvite, dependent: :destroy).
    • Has many collective_goals (dependent: :destroy).
    • Has many leaderboard_entries (TeamLeaderboardEntry, dependent: :destroy).
  • Key Fields:
    • name (string, not null)
    • slug (string, not null, unique) — used in Team-facing URLs.
  • Usage: Created at Team checkout (Phase 2+). Community#team is nullable — nil means a free/public community, unaffected by Teams.

Team Subscription (TeamSubscription)

Links a Team to a Plan and a Stripe subscription; tracks seat count and lifecycle status.

  • Inherits from: PublicRecord (has public_id, no acts_as_paranoid — a Team has at most one subscription record, replaced rather than soft-deleted on plan changes)
  • Key Associations:
    • Belongs to a Team (unique index — one subscription per Team).
    • Belongs to a Plan.
  • Key Fields:
    • stripe_subscription_id, stripe_customer_id (string, nullable)
    • seat_count (integer, not null, default 1, must be > 0)
    • status (string, not null, default 'trialing') — one of trialing, active, past_due, grace, canceled (TeamSubscription::STATUSES). The whole lifecycle is Stripe-webhook-driven, not clock-driven: 'active' is set by Teams::ProcessInvoicePaid on invoice.paid, transitioning trialing/past_due/graceactive (OBJ-1411); 'past_due' is set by Teams::ProcessInvoicePaymentFailed on invoice.payment_failed, transitioning trialing/activepast_due and stamping current_period_end to the failure moment (OBJ-1412). Only the past_due → grace → canceled sweep (7+7 days) remains a daily job (Teams::PaymentFailedJob), keyed off current_period_end; Teams::TrialExpiredJob's daily sweep is a safety-net Sentry alarm only, not a state writer, since OBJ-1412. Auto-cancellation calls StripeService.cancel_subscription before writing canceled locally, and skips (fails closed) if Stripe still reports the subscription active. canceled is deliberately not a recovery source — reactivation requires a fresh checkout.
    • current_period_end, trial_ends_at (datetime, nullable)
  • Key Scopes: .active (status: 'active') — matches subscriptions that have converted via invoice.paid
  • Seed data (BILLING-4, Phase 1): two Plan rows are seeded by the SeedTeamPlans migration, following the same migration-based seeding pattern as SeedSupporterPlans:
Plan.slugNamePriceInterval
teams_monthlyTeams Monthly700¢ ($7.00) / seatmonth
teams_annualTeams Annual7000¢ ($70.00) / seatyear

Plan.kind (string, default 'supporter', one of Plan::KINDS = %w[supporter team]) is the mechanism that separates the two product lines — the teams_monthly/teams_annual rows above are backfilled to kind: 'team'. The .supporter/.team scopes filter on it, and the root GraphQL plans resolver uses Plan.active.supporter.order(:price_cents) so Teams plans never leak into the Supporter plan selector.

Team Membership (TeamMembership)

The seat — join record between a User and a Team.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid — preserves leaderboard history as "Former member" instead of losing the row.
  • Key Associations:
    • Belongs to a Team.
    • Belongs to a User.
  • Key Fields:
    • role (string, not null, default 'member') — one of owner, admin, member, external_coach (TeamMembership::ROLES)
    • joined_at (datetime, not null)
  • Constraints: Partial unique index on [team_id, user_id] where deleted_at IS NULL — a user can hold only one active membership per Team, but can re-join (new row) after a soft-deleted one.

Team Leaderboard Entry (TeamLeaderboardEntry)

Denormalized leaderboard row per (team, community, user, period). Internal model — not exposed via public_id.

  • Inherits from: ApplicationRecord
  • Key Associations:
    • Belongs to a Team.
    • Belongs to a Community (optional — nil means the entry is team-wide, not scoped to a sub-community).
    • Belongs to a User.
  • Key Fields:
    • scope (string, not null) — one of team_wide, sub_community (TeamLeaderboardEntry::SCOPES)
    • period (string, not null) — one of weekly, monthly, all_time (TeamLeaderboardEntry::PERIODS)
    • period_start (date, nullable)
    • points (integer, not null, default 0, >= 0)
    • rank (integer, nullable)
    • computed_at (datetime, nullable)
  • Constraints: Unique index on [team_id, community_id, user_id, period, period_start] with nulls_not_distinct: true (Postgres 15) — one entry per user per scope/period even when community_id/period_start are NULL.

Collective Goal (CollectiveGoal)

A team- or sub-community-scoped shared goal, separate from a personal Goal.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp).
  • Key Associations:
    • Belongs to a Team.
    • Belongs to a Community (optional — nil means team-wide; see #team_wide?).
    • Belongs to a created_by (User).
    • Has many contributions (CollectiveGoalContribution, dependent: :destroy).
  • Key Fields:
    • name (string, not null), description (text, nullable)
    • target_value (integer, not null, > 0)
    • target_metric (string, not null) — one of check_ins, milestones_completed, members_active_days, custom (CollectiveGoal::TARGET_METRICS). All three non-custom values are aggregated (OBJ-1834) — see events_contributed below and Collective Goal Contribution Credit.
    • aggregation_window_start, aggregation_window_end (datetime, nullable)
  • Key Logic: #team_wide? returns true when community_id is nil.

Collective Goal Contribution (CollectiveGoalContribution)

Opt-in record linking a User to a CollectiveGoal via their personal Goal. Internal model — not exposed via public_id.

  • Inherits from: ApplicationRecord
  • Key Associations:
    • Belongs to a CollectiveGoal.
    • Belongs to a User.
    • Belongs to a personal_goal (Goal, optional).
  • Key Fields:
    • events_contributed (integer, not null, default 0, >= 0) — incremented by whichever job handles the contribution's target_metric (Teams::CollectiveGoalContributionUpsertJob for check_ins/milestones_completed, Teams::CollectiveGoalActiveDayUpsertJob for members_active_days), fired off the same check-in/goal-event call sites as Teams::TeamLeaderboardUpsertJob, never inline on the personal check-in path. check_ins increments via a single atomic UPDATE ... SET events_contributed = events_contributed + 1; milestones_completed and members_active_days additionally claim a row in CollectiveGoalContributionCredit first, so a milestone toggled complete → incomplete → complete or a backdated/retried check-in only credits once (OBJ-1834). Collective goals created on milestones_completed/members_active_days before OBJ-1834 shipped are not backfilled — they count from ship-forward only.
    • opted_in_at, opted_out_at (datetime, nullable) — opt-out sets opted_out_at rather than destroying the row (same nullable-timestamp idiom as team_memberships.leaderboard_opted_out_at), so events_contributed history survives a later re-opt-in.
  • Key Scopes: .active (opted_out_at: nil) — contributions currently counted toward the goal; used by CollectiveGoalPresenter and the contribution-upsert job.
  • Key Logic: #opted_out? returns true when opted_out_at is present.
  • Constraints: Unique index on [collective_goal_id, user_id] — one contribution record per user per goal.

Collective Goal Contribution Credit (CollectiveGoalContributionCredit)

Append-only idempotency ledger row claiming exactly one events_contributed increment on a CollectiveGoalContribution. Added in OBJ-1834 to make the milestones_completed and members_active_days aggregations safe against Sidekiq retries and repeat triggers (a milestone's completed flag flipping false → true more than once; the same member logging several qualifying activities on one calendar day). The check_ins metric doesn't use this table — it keeps its original non-ledgered atomic increment. Internal model — not exposed via public_id.

  • Inherits from: ApplicationRecord (same "internal telemetry" precedent as CollectiveGoalContribution — no PublicRecord, no acts_as_paranoid; never soft-deleted)
  • Key Associations:
    • Belongs to a CollectiveGoalContribution.
  • Key Fields:
    • dedupe_key (string, not null) — "milestone:<milestone_public_id>" for milestones_completed, or "active_day:<YYYY-MM-DD>" (member's own timezone) for members_active_days.
  • Constraints: Unique index on [collective_goal_contribution_id, dedupe_key]. A credit is claimed via INSERT ... ON CONFLICT DO NOTHING; events_contributed increments only when the insert actually creates a row, so a losing/duplicate insert is a no-op.

Team Invite (TeamInvite)

Invite record — link- or email-targeted, optionally pre-selecting sub-communities.

  • Inherits from: PublicRecord
  • Soft Deletion: Uses acts_as_paranoid (has deleted_at timestamp).
  • Key Associations:
    • Belongs to a Team.
    • Belongs to an invited_by (User).
    • Belongs to an accepted_by (User, optional).
  • Key Fields:
    • code (string, not null, unique)
    • email (string, nullable) — nil means an open link invite.
    • team_role (string, not null, default 'member') — one of admin, member (TeamInvite::TEAM_ROLES; owner is not invitable).
    • preselected_community_ids (bigint array, default [], not null)
    • expires_at (datetime, not null)
    • max_uses (integer, default 1, nullable — nil means unlimited)
    • used_count (integer, not null, default 0, >= 0)
    • status (string, not null, default 'pending') — one of pending, accepted, revoked, expired (TeamInvite::STATUSES)
  • Key Scopes: .pending (status: 'pending')

Team Access Policy (TeamAccessPolicy, service — not a model)

Centralizes every "can user X read/mutate Y in Team T" check. Every Teams read and mutate path is required to go through this policy — no inline role checks.

  • Location: app/services/team_access_policy.rb
  • Phase 2 status: live enforcement. TeamAccessPolicy.new(user:, team:).can?(action) looks up the caller's TeamMembership#role for the team and checks it against the GRANTS map:
    • view_team_dataowner, admin, member, external_coach (anyone seated on the team)
    • manage_teamowner, admin (roster, sub-communities, collective goals)
    • manage_billingowner only (checkout, seat changes, ownership transfer, cancellation)
    • A caller with no membership on the team (membership_role returns nil) fails every can? check.
  • Wired everywhere: every Teams::* interaction (app/interactions/teams/*.rb — invites, membership changes, sub-community management, billing) calls policy.can?(...) in an authorized? guard and fails with code: :forbidden when it returns false. The read side (Resolvers::TeamQueries#team_settings/#team_home) enforces membership via the PRIVACY-2 team_scoped gate rather than TeamAccessPolicy directly, since those two reads are membership-gated, not role-gated.

Team Pulse Response (TeamPulseResponse)

A member's quarterly NPS-style pulse survey response for a Team — the "feel surveilled" zero-tolerance anti-metric signal called out in the PRD §10 note. Net-new in Phase 7 GA (OBJ-1209/1210/1211); Phase 6 never actually shipped this despite Phase 7's original task package assuming a carry-forward. Internal model — not exposed via public_id.

  • Inherits from: ApplicationRecord (same "internal telemetry" precedent as TeamLeaderboardEntry/CollectiveGoalContribution — no PublicRecord, no acts_as_paranoid)
  • Key Associations:
    • Belongs to a Team.
    • Belongs to a User.
  • Key Fields:
    • score (integer, not null) — 0–10, validated via inclusion: { in: 0..10 }.
    • comment (text, nullable) — free-text, optional.
    • period (string, not null) — quarter key, e.g. "2026-Q3", computed server-side from the current date (Teams::SubmitTeamPulse#period) — never client-supplied.
  • Constraints: Unique index index_team_pulse_responses_on_team_user_period on [team_id, user_id, period] — one response per member per quarter. period validation additionally enforces uniqueness: { scope: %i[team_id user_id] } at the model level as a second guard.
  • Data boundary (PRD §6, law): Objectuve-internal telemetry only. No team-admin read path exists in scope — no query anywhere joins this table into a team-data aggregate or lets a team admin/owner read member responses. Mutations::Teams::SubmitTeamPulse deliberately omits score/comment from its return payload (TeamPulseConfirmationType exposes only period) so the client never receives a receipt of what was submitted.
  • Usage: Written by Teams::SubmitTeamPulse (app/interactions/teams/submit_team_pulse.rb), guarded by TeamAccessPolicy#can?(:view_team_data) (reuses the existing grant — no new policy action) and a same-period duplicate check. Read-side eligibility (has the viewer already responded this quarter?) is exposed narrowly via the teamPulseEligibility(teamId:) GraphQL query — see GraphQL API Reference § Team Pulse Survey. Live in production since the teams_enabled flag retirement at GA (PR #1498, OBJ-1096/OBJ-1223) — gated by standard authentication and membership checks only, no feature flag.

Existing model extensions (Phase 1)

  • Community: gains belongs_to :team, optional: true (nullable FK — nil means a free/public community, unaffected by Teams) and is_default_for_team (boolean, not null, default false).
  • PaymentRecord: gains belongs_to :team_subscription, optional: true — lets a payment record settle either a personal Supporter plan (plan_id) or a Team subscription seat invoice (team_subscription_id).

Entity Relationship Diagram

erDiagram
    User ||--o{ Goal : "creates"
    User ||--o{ UserAction : "performs"
    User ||--o{ CommunityMember : "joins via"
    User ||--o{ UserFeedItem : "receives"
    User ||--o{ MoodLog : "logs"
    User ||--o{ CriticalPathPlay : "solves puzzle"
    User ||--o{ StimXpLedger : "earns XP via"
    User ||--o{ UserAlly : "allies with"
    User ||--o{ AllyInvite : "creates invites"
    User ||--o{ ChallengeParticipant : "participates in"
    User ||--o{ EnneagramAssessment : "completes"
    User ||--|| UserDetail : "has profile + coaching prefs"
    CriticalPathPlay ||--o{ StimXpLedger : "may trigger"

    Community ||--o{ CommunityMember : "has members"
    Community ||--o{ CommunityGoal : "tracks"
    Community ||--o{ CommunityChallenge : "hosts"
    Community ||--o{ CommunityFeedItem : "publishes"

    CommunityChallenge ||--o{ ChallengeParticipant : "has participants"

    Goal ||--o{ GoalEvent : "logs progress"
    Goal ||--o{ HabitCompletion : "checked in"
    Goal ||--o{ Milestone : "has sub-goals"
    Goal ||--o{ CommunityGoal : "shared to"
    Goal ||--o{ MoodLog : "referenced by"
    Goal }|--|| GoalCategory : "classified by"

    GoalEvent ||--o{ GoalEventEncouragement : "receives likes"
    GoalEvent ||--o{ GoalEventComment : "receives comments"
    GoalEvent ||--o{ GoalMedia : "has attachment"

    AiEmployee ||--o{ AiRun : "executes"
    AiEmployee ||--o{ AiEmployeeMemory : "maintains"
    AiRun ||--o{ AiArtifact : "produces"
    AiArtifact ||--o{ DesignAsset : "references via payload (ui_design kind)"

    Team ||--o| TeamSubscription : "has"
    Team ||--o{ TeamMembership : "has seats"
    Team ||--o{ Community : "owns (nullable)"
    Team ||--o{ TeamInvite : "issues"
    Team ||--o{ CollectiveGoal : "hosts"
    Team ||--o{ TeamLeaderboardEntry : "ranks"
    Team ||--o{ TeamPulseResponse : "collects"
    User ||--o{ TeamPulseResponse : "submits"
    User ||--o{ TeamMembership : "holds seat via"
    User ||--o{ TeamInvite : "invited by / accepts"
    User ||--o{ CollectiveGoalContribution : "opts in via personal Goal"
    Community ||--o{ TeamLeaderboardEntry : "scopes (optional)"
    Community ||--o{ CollectiveGoal : "scopes (optional)"
    CollectiveGoal ||--o{ CollectiveGoalContribution : "receives"
    CollectiveGoalContribution ||--o{ CollectiveGoalContributionCredit : "claims via"
    TeamSubscription }|--|| Plan : "subscribes to"
    TeamSubscription ||--o{ PaymentRecord : "settled by"

Base Classes

ApplicationRecord

The standard Rails base class for models.

  • Behaviors:
    • Standard ActiveRecord functionality.
    • Time helpers (converting timestamps to integer strings for API consistency).

PublicRecord

A specialized abstract class for models that need to be exposed publicly.

  • Behaviors:
    • Generates a unique public_id (url-safe base64 token) on creation.
    • Validates uniqueness of public_id.
    • Provides find_public(id) helper for secure lookups.

Reference Data Models

Feed Kind (FeedKind)

Enumerated reference data for feed item types shown in community and user activity feeds.

  • Inherits from: ApplicationRecord (seeded reference data, not user-facing)
  • Key Fields:
    • name (string, unique) — feed kind identifier (e.g., join, completed, update, challenge_completed)
    • deleted_at (datetime, nullable) — soft deletion timestamp via acts_as_paranoid
  • Usage: Lookup table referenced by UserFeedItem and CommunityFeedItem via foreign key feed_kind_id. Populated via database seeds; never created dynamically.
  • Current kinds (defined in db/seeds/production.rb):
    • join — User joined the community
    • completed — User completed a goal
    • updated — User posted a goal update or milestone
    • add — User added a goal
    • encouragement — User received an encouragement
    • badge — User earned a badge
    • general — General activity feed entry
    • comment — User received a comment
    • challenge_completed — Community challenge ended with completers (community-level feed entry summarizing how many participants finished)

AI Workforce Models

The AI Workforce system uses four models for managing autonomous AI employees. All inherit from PublicRecord and use acts_as_paranoid.

AiEmployee

Represents a configured AI agent that runs on a schedule to produce artifacts.

  • Inherits from: PublicRecord
  • Key Associations:
    • Has many AiRuns
    • Has many AiEmployeeMemorys
  • Key Fields:
    • name, role_key (unique) — identity
    • skill_refs (string array) — skill names loaded into system prompt
    • mcp_servers (string array) — scoped tool access (e.g., ['github', 'sentry'])
    • task_prompt (text) — instructions for the agent
    • schedule_cron (string) — cron expression for automatic runs
    • autonomy_level (string) — shadow, semi_autonomous, or autonomous
    • monthly_budget_cents (integer, default: 3000) — monthly spend cap in cents. Resets on the first of each calendar month.
    • model_preference (string) — LLM model routing preference
    • post_filter_skill (string) — brand-voice filter skill reference
    • max_turns (integer, default: 30) — max conversation turns per run
    • output_kind (string) — expected artifact type
    • promotion_confirmed_at (datetime) — tracks operator-confirmed autonomy promotions
  • Computed properties (not stored columns):
    • current_month_cost_cents — Sum of cost_cents across all AiRuns created since the start of the current calendar month (UTC). Recalculated on each call; used to determine if the monthly budget is exhausted.
    • budget_remaining_centsmonthly_budget_cents - current_month_cost_cents. Indicates how much budget remains in the current calendar month.
    • budget_exhausted? — Boolean predicate: true if current_month_cost_cents >= monthly_budget_cents.
  • Scopes: .active, .scheduled, .due_for_run
  • Validations: Promotion gates enforced as database validations (validate :promotion_requirements_met)

Budget Enforcement Pattern (Three-Layer Defense)

Enkidu enforces monthly spend caps through three independent defense layers to ensure budget exhaustion is caught reliably even under edge cases (stale data, concurrent requests, clock skew):

Layer 1 — Rails gate (primary enforcement):

  • Location: AiWorkforce::TriggerRun interaction (called before enqueueing a new run)
  • Check: Calls employee.budget_exhausted? (compares current month's summed AiRun costs against monthly_budget_cents)
  • Action on breach: Sets employee.active = false (auto-pauses the employee) and returns a failure result with error code :budget_exceeded. The run is never created or enqueued.
  • Reasoning: This is the primary control — once a run is queued, cost tracking becomes distributed (agent_runner may be processing it while the webhook callback is in flight). Rejecting at enqueueing time ensures clean state.

Layer 2 — Agent Runner defense-in-depth (redundant check):

  • Location: POST /runs route handler in agent_runner/src/routes/runs.ts
  • Check: Guards against stale data by re-validating currentMonthCostCents >= monthlyBudgetCents when the dispatcher lands at the agent runner
  • Action on breach: Returns HTTP 422 with error 'budget_exhausted'. The run does not execute.
  • Reasoning: If a run dispatch is queued before the monthly cutover, or if cost data becomes slightly stale between Rails and agent_runner, this layer catches it before the agent starts executing.

Layer 3 — Per-run budget cap (circuit breaker):

  • Location: Agent Runner's runAgent function in agent_runner/src/agent/runner.ts (budget cap computed at lines 23-28, wired via maxBudgetUsd: runBudgetUsd at line 69)
  • Mechanism: Each run's budget is capped to Math.min(MAX_RUN_BUDGET_USD, remainingMonthlyUsd), where remainingMonthlyUsd = (monthlyBudgetCents - currentMonthCostCents) / 100
  • Action: If the remaining monthly budget is less than MAX_RUN_BUDGET_USD (e.g., $1.00 default), the run's effective budget becomes the remainder, capping token consumption and tool call depth
  • Reasoning: Provides soft limiting that prevents a run from over-spending even if Layers 1 and 2 somehow allow a run to proceed near the cap. This is a last-resort safeguard; the first two layers should have rejected the run if the cap is truly exhausted.

Monthly reset behavior:

  • The calendar month resets on the 1st UTC. The calendar month boundary is calculated once in Rails using Time.current.beginning_of_month and passed to Layers 2 and 3 as currentMonthCostCents.
  • Toggling employee.active = false by Layer 1 does not auto-reset on month boundary; it requires manual operator action to set active = true.

AiRun

Records execution lifecycle and cost tracking for each agent run.

  • Inherits from: PublicRecord
  • Key Associations:
    • Belongs to AiEmployee
    • Has many AiArtifacts
  • Key Fields:
    • status (string) — queued, running, succeeded, failed
    • triggered_by (string) — schedule or manual
    • started_at, finished_at (datetime) — execution timestamps
    • prompt_tokens, completion_tokens (integer) — token counts
    • cost_cents (integer) — run cost from LiteLLM
    • run_log (jsonb) — execution details including tool calls
    • error_message (text) — failure reason
  • Scopes: .active (queued or running), .recent

AiArtifact

Durable artifact produced by a run, pending operator review.

  • Inherits from: PublicRecord
  • Key Associations:
    • Belongs to AiRun
  • Key Fields:
    • kind (string) — draft_post, code_patch, email_template, report, recommendation, ui_design
    • title (string), payload (jsonb) — artifact content
    • approval_status (string) — pending, approved, rejected, auto_rejected
    • rejection_reason (text) — reason if rejected
    • edit_distance (float) — Levenshtein ratio if edited before approval
    • delivered_at (datetime), delivery_metadata (jsonb) — delivery tracking
    • reviewed_by (integer), reviewed_at (datetime) — reviewer info
    • claimed_by (integer), claimed_at (datetime) — set by the claimAiArtifact mutation (v4.8, OBJ-1562) when an operator takes a pending artifact for themselves without resolving it; approval_status stays pending. claimed_at is the sole signal the Ops Board uses to move the ticket into its "Handed to you" column, so the hand-off survives the board's 30s poll refresh.
  • Scopes: .pending, .approved, .rejected, .auto_rejected
  • UI Design artifacts (kind: ui_design) — Produced by the Desi agent (Tier 3) during design iteration. Payload contains references to DesignAsset objects captured during rendering. Reviewable in the admin dashboard's AI Workforce Ops Board via 6-up screenshot grid with viewport + theme variants.

AiEmployeeMemory

Persistent structured memory loaded into agent system prompt.

  • Inherits from: PublicRecord
  • Key Associations:
    • Belongs to AiEmployee
  • Key Fields:
    • memory_key (string) — identifier (e.g., last_run_summary, performance_stats, recent_rejections)
    • memory_data (jsonb) — arbitrary structured content
  • Constraints: Unique index on [ai_employee_id, memory_key]

DesignAsset

Durable PNG capture produced by Desi during UI mockup rendering (Tier 3).

  • Inherits from: PublicRecord
  • Key Associations:
    • Has one attached image via ActiveStorage
  • Key Fields:
    • viewport (string, nullable) — one of mobile, tablet, desktop
    • theme (string, nullable) — one of light, dark
  • Constraints: VIEWPORTS = %w[mobile tablet desktop], THEMES = %w[light dark]
  • Usage: Each DesignAsset record represents a single screenshot from the Playwright renderer CLI (npm run desi:render). A single mockup HTML file rendered across 3 viewports × 2 themes = 6 DesignAsset records. URLs (from ActiveStorageUrl.for(asset.image)) are collected in the parent AiArtifact's payload and displayed as a 6-up grid in the admin dashboard's AI Workforce Ops Board.

AI Coaching Interactions

These are not ActiveRecord models — they are Rails interactions and GraphQL mutations in the coaching inference layer. Documented here because they are closely coupled to the User and Goal predicates described above.

Ai::EnsureTodaysCoachMessage

Generates the user's coach-home synopsis on-demand for the current local day.

  • Location: rails_api/app/interactions/ai/ensure_todays_coach_message.rb
  • Input: user — the current authenticated user
  • Cache key format: ai:insight:v1:<user_public_id>:coach-home:<YYYY-MM-DD> — date is the user's local date, resolved via Time.use_zone(user.timezone_or_default) { Date.current }. The VERSION constant (Ai::Coaching::DailyInsightPackKey::VERSION) can be bumped to invalidate all cached packs.
  • TTL: 24 hours
  • Return shape: { insight: String, generated: Boolean }
  • Cache-hit path: returns cached insight immediately; generated: false. No LLM call.
  • Cache-miss path: calls Ai::CoachService#generate_synopsis(user) to produce a fresh synopsis; caches the result; generated: true. Falls back to Ai::Fallbacks::Insight.coach_home if generate_synopsis returns nil.
  • Error path: any StandardError is rescued, logged, and captured in Sentry. Returns Ai::Fallbacks::Insight.coach_home with generated: false. Interaction does not result.fail() — caller always receives a usable insight.
  • Cache value shape (Phase 116 addition): the cached hash now includes source: 'daily_load' so the completion follow-up interaction (Ai::EnsureCompletionFollowUpCoachMessage) can distinguish initial from follow-up entries.
  • Telemetry: fires AiCoaching::DailyTriggerEvent with source: :daily_load and generated: <bool> in all three paths. Event is captured via PostHog as ai_coaching.daily_trigger.

ensureTodaysCoachMessage GraphQL mutation

  • Location: rails_api/app/graphql/mutations/ai/ensure_todays_coach_message.rb
  • Description (from GraphQL schema): "Ensures a coach message exists for the user today, generating one via AI on the first daily call."
  • Auth: require_auth! — authenticated users only.
  • Fields returned:
    • insight: String! — the synopsis text
    • generated: Boolean! — whether a new LLM call was made (true) or a cache hit served the response (false)
  • Idempotency: safe to call multiple times per day. Cache hit on second call means no additional LLM cost.
  • Fire site: ionic_frontend/src/components/coach/CoachSynopsisCard.vue onMounted — fires before fetchInsight({ page: 'coach-home' }). The mutation result is not consumed directly by the component; the useAiInsights read path reads the insight the mutation has ensured into the cache.

Ai::EnsureCompletionFollowUpCoachMessage

Overwrites the coach-home cache entry with a follow-up synopsis once the user's required check-ins are all complete for the day.

  • Location: rails_api/app/interactions/ai/ensure_completion_follow_up_coach_message.rb
  • Input: user — the current authenticated user
  • Cache key format: same ai:insight:v1:<user_public_id>:coach-home:<YYYY-MM-DD> key as Ai::EnsureTodaysCoachMessage — overwrites in place
  • Trigger: called from AiCoaching::GenerateFollowUpCoachMessageJob, which is enqueued by Ai::Subscribers on every goal_tracking.habit_checked_in event
  • Signal check: evaluates User#required_checkins_complete_today? first; returns immediately (no-op) when false
  • Idempotency guard: reads existing cache entry; returns immediately when existing[:source] == 'completion_followup' (prevents re-generation on re-flip)
  • Write path: calls Ai::CoachService#generate_synopsis(user); writes result (or Ai::Fallbacks::Insight.coach_home on nil) to cache with source: 'completion_followup', 24-hour TTL
  • Error path: any StandardError is rescued, logged, and captured in Sentry. Intentionally does not write a fallback to cache — the morning message remains until the 24-hour TTL expires
  • Telemetry: fires AiCoaching::DailyTriggerEvent with source: :completion_followup, generated: <bool>, and signal_flipped_in_this_call: true (always true at the call site — the re-flip no-op guard returns before this point). On error, fires with generated: false (no signal_flipped_in_this_call property)

Ai::Subscribers

Boot-time domain event subscriber registry for the AI coaching context.

  • Location: rails_api/app/interactions/ai/subscribers.rb
  • Registration: Ai::Subscribers.register is called from rails_api/config/initializers/domain_event_subscribers.rb inside after_initialize. Idempotent via @registered guard
  • Subscriptions:
    • goal_tracking.habit_checked_in → resolves user by event.payload[:user_id] (public_id), enqueues AiCoaching::GenerateFollowUpCoachMessageJob with integer user.id
  • Error handling: the subscriber handler wraps the enqueue in a rescue boundary. A SolidQueue or Redis failure does not propagate to the check-in mutation thread

Ai::EnsureAtRiskGoalCoachMessage (Phase 117)

Writes a coach-home nudge when a recurring habit goal is measurably behind its own cadence with little time left in its period. Full recurrence-aware "at risk" definition: docs/features/coach.md § At-risk-goal nudge (Phase 117).

  • Location: rails_api/app/interactions/ai/ensure_at_risk_goal_coach_message.rb
  • Input: user — the current authenticated user
  • Trigger: called from AiCoaching::GenerateAtRiskCoachMessageJob, an hourly Crono sweep (rails_api/config/cronotab.rb) rather than a domain-event subscriber — the "little time left" condition is time-based, not event-triggered
  • Goal selection: picks the single most-at-risk habit goal via Goal#at_risk? (AtRiskCadence concern), ranked by least time left in its period, then lowest completion rate
  • Dedup: CoachTriggerDispatch.exists?(goal_id:, trigger_kind: 'at_risk_goal', period_key:) — durable, DB-unique-indexed, not cache-only (see CoachTriggerDispatch above)
  • Precedence guard: does not overwrite a same-day completion_followup or at_risk_goal cache entry; may overwrite a daily_load synopsis
  • Write path: calls Ai::CoachService#generate_at_risk_nudge(user, goal); writes result (or Ai::Fallbacks::Insight.coach_home on nil) to the same coach-home cache key with source: 'at_risk_goal', 24-hour TTL; records a CoachTriggerDispatch row
  • Error path: any StandardError is rescued, logged, and captured in Sentry. Intentionally does not write a fallback to cache — the existing message is left untouched (same fail-open pattern as Phase 116)
  • Telemetry: fires AiCoaching::DailyTriggerEvent with source: :at_risk_goal and generated: <bool> in all paths

AiCoaching::DailyTriggerEvent (Phase 116/117 extension)

The signal_flipped_in_this_call keyword was added in Phase 116 (optional; nil default — backwards-compatible with Phase 115). When present, it is included in the PostHog ai_coaching.daily_trigger event properties. For source: :completion_followup, this property is always true at the call site because the re-flip no-op path returns before the telemetry call. Phase 117 adds a third source value, :at_risk_goal, which does not set signal_flipped_in_this_call (no comparable flip signal for a time-based trigger).

AiCoaching::GenerateDailyInsightPackJob

The daily batch job that pre-bakes insight packs for five page surfaces. Runs once daily at 06:00 UTC.

  • Location: rails_api/app/jobs/ai_coaching/generate_daily_insight_pack_job.rb
  • Schedule: 06:00 UTC daily (via cron/scheduler)
  • Pages pre-baked: dashboard, goals, achievements, communities, goal-detail (five pages)
  • coach-home excluded since Phase 117 (v3.12.5): coach-home was removed from Ai::Coaching::DailyInsightPackKey::PAGES. The coach-home surface is event-driven via Ai::EnsureTodaysCoachMessage and Ai::EnsureCompletionFollowUpCoachMessage.
  • Cache key format: ai:insight:v1:<user_public_id>:<page>:<YYYY-MM-DD> using the UTC date (not user-local). Only coach-home keys use the user-local date.
  • Eligible users: users with at least one active (non-completed) goal whose coaching rhythm is not when_needed.
  • Fallback: on LLM failure, writes Ai::Fallbacks::Insights.for(persona:, surface: :generic_insight) to cache.
  • Telemetry: PostHog ai_insight_generated event per (user, page) pair, with source: 'ai' or 'fallback'.

UserType#currentInsights resolver

The currentInsights GraphQL field assembles insight packs for the authenticated user — batch-page entries from the job above plus the event-driven coach-home entry.

  • Location: rails_api/app/graphql/types/user_type.rb, def current_insights
  • coach-home read: the resolver reads the coach-home cache entry using a user-local-day key — Time.use_zone(object.timezone_or_default) { Date.current } — to match the key written by the event triggers. This is distinct from the UTC-dated keys used for batch pages.
  • Assembly: (batch_insights + [coach_home_insight]).compact — a nil entry (no coach-home message yet for today) is compacted out so the client never receives a nil insight.

Async AI Delivery (v4.60 Phase 1)

Durable substrate for moving AI work off the request thread — today, seven GraphQL mutations call Ai::CoachService inline during resolve, each holding a Puma thread and its checked-out ActiveRecord connection for up to the schema's max_seconds: 30 timeout. This phase (OBJ-3758) ships the machine and wires nothing to it yet: no GraphQL mutation references any of it, and the seven mutations above are still fully synchronous. The cutover happens mutation-by-mutation in later phases of the v4.60 milestone. Documented as its own section rather than folded into AI Coaching Interactions above, since this is general async-request infrastructure, not part of the coaching inference layer itself.

AiRequest

Durable correlation row for one async AI request. Not a history table — coach_messages already owns conversation history; this row's job is correlation and reconnect-recovery.

  • Inherits from: PublicRecord
  • Key Associations: belongs to User
  • Key Fields:
    • kind (string, required) — discriminator naming which handler produced (or will produce) the result
    • status (enum: pending / succeeded / failed, default pending)
    • result (jsonb, default {}) — the handler's success payload. Exposed over GraphQL as a resultJson string field rather than a payload-type union (Q-1) — each frontend composable parses resultJson into its own already-typed shape
    • error_code, error_message (string / text) — populated on failure
    • request_digest (string, nullable) — correlation aid
    • completed_at (datetime) — set on both success and failure
  • Constraints: acts_as_paranoid; unique index on public_id; index on (user_id, created_at)
  • Retention: AiRequest::RETENTION_WINDOW = 7 days (Q-2). The retention_eligible scope selects succeeded/failed rows with completed_at older than the window; pending rows are never touched by it, regardless of age.
  • GraphQL surface: aiRequest(id:) query and the aiRequestUpdate subscription — see GraphQL Reference § Async AI Delivery and API Reference § Real-time Subscriptions.
  • GDPR erasure: hard-deleted (AiRequest.with_deleted.where(user_id: ...).delete_all) via Gdpr::UserDataCascade#hard_delete_leaf_owned_records! — see Data Deletion § Hard-deleted.

Ai::DispatchAiRequestJob + Ai::AiRequestHandlerRegistry

One generic dispatcher job for every async AiRequest kind, rather than a job per mutation.

  • Location: rails_api/app/jobs/ai/dispatch_ai_request_job.rb, rails_api/app/services/ai/ai_request_handler_registry.rb
  • Flow: looks up request.kind in Ai::AiRequestHandlerRegistry, calls the handler with the AiRequest, and persists the outcome — succeeded + result on success, failed + error_code/error_message on any raised error — then broadcasts on Subscriptions::AiRequestUpdate either way. Fail-open by construction: a handler raising never bubbles past this job.
  • Idempotency guard: returns immediately if the request is missing or no longer pending, so a Sidekiq retry can't re-run an already-completed request's handler.
  • Handler registry: Ai::AiRequestHandlerRegistry.register(kind, handler) — a handler is any object responding to #call(ai_request), returning the result Hash to persist on success or raising to signal failure. handler_for raises Ai::AiRequestHandlerRegistry::UnknownKind for an unregistered kind (surfaced as error_code: 'unknown_kind'). Zero handlers are registered as of Phase 1 — real per-kind handlers land in later cutover phases of this milestone.

Ai::PurgeCompletedAiRequestsJob

Retention sweep for AiRequest (Q-2).

  • Location: rails_api/app/jobs/ai/purge_completed_ai_requests_job.rb
  • Schedule: daily at 04:15 UTC via Crono (Crono.perform(Ai::PurgeCompletedAiRequestsJob).every 1.day, at: '04:15', rails_api/config/cronotab.rb)
  • Behavior: AiRequest.retention_eligible.delete_all — a real hard delete (this app never sets Paranoia.delete_all_enabled), not paranoia's soft-delete shim. On failure, reports to Sentry and re-raises.

Important Note on Deletion (acts_as_paranoid)

Many core models (User, Goal, GoalEvent, CommunityChallenge, etc.) use the acts_as_paranoid gem.

  • Records are not physically deleted from the database.
  • A deleted_at timestamp is set instead.
  • default_scope filters out deleted items.

Last updated: 2026-09-14 (OBJ-3758 — v4.60 Phase 1, Async AI Delivery Path: documented AiRequest, Ai::DispatchAiRequestJob + Ai::AiRequestHandlerRegistry, and Ai::PurgeCompletedAiRequestsJob. Backend-only substrate — no GraphQL mutation references it yet)

Previously: 2026-08-14 (OBJ-1834 — added CollectiveGoalContributionCredit, the idempotency ledger backing milestones_completed/members_active_days aggregation; dropped the GOALS-13 "only check_ins is aggregated" caveat from CollectiveGoal#target_metric and CollectiveGoalContribution#events_contributed now that all three non-custom metrics are live)

Before that: 2026-08-09 (OBJ-2344 — documented new PartnerNudge model (§7b), the partner_nudges ledger table and its composite unique index on (user_ally_id, sender_id, sent_on), backing the DB-enforced per-direction daily nudge rule shipped in OBJ-2343)

Loading…