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 throughCommunityMember. - Has a
UserFeedItemfeed for their personal activity stream. - Has one
UserDetail(extended profile fields and coaching preferences — see below).
- Has many
- Key Fields:
timezone(string, default'UTC') — IANA timezone string (e.g.,'America/Chicago'). Populated from the frontend viaIntl.DateTimeFormat().resolvedOptions().timeZoneon 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 theUser#timezone_or_defaulthelper.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 byUser#update_streak!. Forward-only migration: historical streaks are not re-attributed to the user's local timezone (seedocs/features/gamification-engine.mdfor details).stim_xp_total(integer, default 0) — Lifetime total Stim XP earned. Incremented by entries in thestim_xp_ledgertable (see section 5d below). Used in theme unlock thresholds (CriticalPath::ThemeCatalog::Theme#unlock_thresholdis compared againststim_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 againstCriticalPath::ThemeCatalog::Theme#unlock_threshold(each entry in theTHEMESarray defined inrails_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, defaultfalse,null: false) — Opts the user out of ally/community/coach-social prompts. Checked bySocial::FindSuggestedAlliesandCommunityQueries#recommended_communities, gated onFeatureFlagService.enabled?('privacy_controls_v1', user: <the user being suppressed>)— inert when the flag is off for that user. Behind flagprivacy_controls_v1.private_mode(boolean, defaultfalse,null: false, indexed) — Hides the user fromsearch_users(username search) results.Resolvers::UserQueries#search_usersgates 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 flagprivacy_controls_v1.- Both fields are set via the self-only
updatePrivacySettingsmutation (UserIdentity::UpdatePrivacySettings), which itself fails closed — raisesFORBIDDENwhenprivacy_controls_v1is off for the acting user.
- Key predicates:
User#required_checkins_complete_today?— Returnstruewhen 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-goalexpected_today?andchecked_in_today?predicates — no separate timezone calculation at the aggregate layer. Exposed on the GraphQL surface asrequiredCheckinsCompleteToday: Boolean!onTypes::UserType. Foundation for the v1.23 Phase 115/116 Coach trigger redesign.
- Authentication: Managed entirely by Clerk. The
clerk_user_idfield 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(withacts_as_paranoid) - Belongs to:
User - Storage: All coaching preferences live in
user_details.data(JSONB) and are exposed as ActiveRecord attributes viastore_accessor. - Coaching preferences fields (Phase 0, April 2026 onward):
| Field | Type | Notes |
|---|---|---|
coach_persona | string | One of captain, spark, mirror, rival, sage, analyst. |
coach_tone_brevity | integer 0..4 | X axis on the Tone Pad — Brief (0) ↔ Detailed (4). Stored as string in JSONB; coerced via .to_i on read. |
coach_tone_warmth | integer 0..4 | Y axis on the Tone Pad — Demanding (0) ↔ Gentle (4). Stored as string in JSONB; coerced via .to_i on read. |
coach_depth | integer 1..5 | Response depth axis. Default: 3. |
coach_focus_primary | string | One of streaks, milestones, reflection, accountability. |
coach_focus_secondary | string | null | Same enum, nullable. |
coach_rhythm | string | One of morning, evening, bookends, when_needed. Replaces coach_frequency. |
coach_configured_at | ISO 8601 datetime | Set on first save. Drives coachingPreferences.configured on the GraphQL surface. |
critical_path_active_theme | string | null | The 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_enabled | boolean | Whether the user receives weekly digest emails. Defaults to true when absent. |
weekly_digest_delivery_day | string | Day 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_at | ISO 8601 datetime | null | Written by Social::WeeklyDigestSendJob after successful email delivery; acts as the idempotency guard for the weekly digest send loop. |
- Defaults (returned by
User#coaching_preferenceswhen 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::CoachingPreferencesTypeexposes the same fields plusconfigured: Boolean. Mutation:Mutations::UpdateCoachingPreferences. - Side effect on first save:
UserAction.generate(user_id:, action: :configure_coach)fires once whencoach_configured_attransitions 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_tone | New coach_tone_brevity | New coach_tone_warmth |
|---|---|---|
| 1 | 1 | 4 |
| 2 | 2 | 3 |
| 3 | 2 | 2 |
| 4 | 3 | 1 |
| 5 | 4 | 0 |
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_frequency | New coach_rhythm |
|---|---|
stuck | when_needed |
milestones | when_needed |
minimal | morning |
daily | morning |
active | bookends |
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) andGoalCategory(broad categories). - Has many
GoalEvents (updates, milestones, completion). - Has many
Milestones (sub-goals). - Can be shared to
Communitys.
- Belongs to a
- 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) — Thepublic_idof theGoalTemplatethis goal was created from (if any). Stored as a string to avoid FK constraint coupling; matches theparent_goal_idconvention. 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 asidentity_prompt.completion_reflection(string, nullable, max 200 chars, v3.12) — Optional free-text reflection captured when the goal is marked complete (viaGoalCompletionSheet.vue). Threads intoAi::CoachService#generate_welcome_back's streak-lapse pathway when the user later returns from a lapse. All three v3.12 fields are the onlyGoalcolumns whereupdateGoaldistinguishes an explicitnull(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 computestreak_freezes_available),starter_freezes_granted(integer, default 0 — one-time freeze grant set on habit creation byGoalTracking::AddGoal),streak_repair_eligible_until(datetime, nullable — deadline for the paid streak-repair window after a streak breaks; checked and cleared byGamification::RepairStreak),streak_repaired_count(integer, default 0 — lifetime count of paid streak repairs, incremented byGamification::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 behttpsor a custom app scheme not on theAppLinkValidatable::UNSAFE_APP_LINK_SCHEMESdeny-set, which blockshttp,ftp,javascript,data,file,vbscript,blob,about,chrome,intent,content,ms-appx, anditms-apps;updateGoaltreats an explicitnullas a clear, sameCLEARABLE_NULLABLE_FIELDSpattern asidentity_prompt/past_attempt_context/completion_reflection— see Nullable Field: Clear vs. Omit Gotcha). - Has many
HabitCompletions (habit check-in records). Goal#expected_today?— Returnstruefor active (non-completed) habit goals whose recurrence pattern matches today, regardless of whether a check-in has already been logged. Evaluated inuser.timezone_or_default. All four recurrence types are covered:daily(always true),weekly/custom_days(today's day-of-week inrecurrence_days),interval(days since last check-in ≥recurrence_interval; true if never checked in). Returnsfalsefor non-habit goals and completed habit goals. Foundation forUser#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?. Returnsfalsethe moment a same-day check-in is recorded. This is the predicate consumed by the GraphQLdueTodayfield 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).
- Belongs to a
- 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-innote(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 asnil, never""). No default, no backfill. Behind flagmicro_journal_enabled— the mutation ignores incomingnotewhen the flag is off for the user. Read access is scoped server-side to the completion's owner (HabitCompletionType#notereturnsnilunlesscontext[:current_user].id == object.goal.user_id), so a note never leaks via public/community/ally goal reads. Read by the Coach through the existingBuildCoachContextrecent-reflections signal — no new read path.
- Scopes:
in_current_period(goal),freezes_used - Usage: Created by the
CheckInHabitinteraction on one-tap check-in. Used byUser#update_streak!(unified activity calculation) and legacyProcessHabitStreaksJobroutines 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(hasdeleted_attimestamp). - Key Associations:
- Belongs to a
GoalCategory(optional, for categorization). - No direct "has many Goals" association —
Goal#from_templateassociation does the reverse lookup viapublic_id.
- Belongs to a
- 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 computetarget_dateon 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 viaacts_as_paranoid.
- Key Logic:
- Seeded once in
rails_api/db/seeds/goal_templates.rbwith 28 curator-authored templates (4 per theme × 7 themes) (v1.21 Phase 85). - Accessible via unauthenticated
goalTemplatesGraphQL query. GoalTemplate.ordered_for_pickerscope returns templates ordered by(theme, display_order)for the picker UI.- JSONB milestones validated by custom
milestones_valid_shapevalidator (array of objects, each with required name/order/days_offset_from_start fields).
- Seeded once in
- Usage: Queried by
TemplatePickerModal.vueto render browsable templates. Selected template'spublic_idand milestones prefill the goal form viaGoalCreate.vue handleTemplateSelect(). OnaddGoalmutation success, the templatepublic_idis stored in the new goal'sfrom_template_idfield.
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, nopublic_id, never exposed via GraphQL. - Key Associations:
- Belongs to a
User. - Belongs to a
Goal.
- Belongs to a
- 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 (seeGoal#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::EnsureAtRiskGoalCoachMessageafter 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).
- Belongs to
- Note on habit check-ins (Phase 59+): As of Phase 59, routine habit check-ins are no longer persisted as
GoalEventrecords. TheCheckInHabitinteraction creates aHabitCompletionrecord 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 theGoal#displayable_eventsscope. 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 generateGoalEventrecords 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
CommunityFeedItemfeed.
- Belongs to
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(hasdeleted_attimestamp). - 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) throughChallengeParticipants.
- Belongs to
- 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 abadge_idFK 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 byProcessChallengeCompletionsJobafter 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), orcompleted(end_date < today). - During the active phase, member progress is tracked via
ChallengeParticipantrecords. - On completion of the challenge period, participants who meet the criteria receive the badge and a celebration modal.
- Challenges are created by community members (
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(hasdeleted_attimestamp). 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.
- Belongs to
- 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 reachestarget_goal_count. Used by thecompleted?predicate andcompletedscope.notified_at(datetime, nullable) — Timestamp of the durable badge-push idempotency marker; distinct fromcompleted_at, which is owned by live progress tracking. Set bySocial::FinalizeCommunityChallengeimmediately 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_countis incremented automatically bySocial::IncrementChallengeProgress, called as a best-effort hook fromGoalTracking::AddGoalEventright 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).
- Belongs to a
- Key Fields:
mood— validated string enum:amazing,happy,calm,meh,tired,lownote— 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(hasdeleted_attimestamp) - Associations:
- Belongs to a
User
- Belongs to a
- Key Fields:
scores(JSONB, required) — Normalized type scores, 0–100 scale. Keys are string integers"1"through"9"(nottype_1format). Example:{ "1" => 100, "2" => 78, "3" => 78, ..., "9" => 0 }. FrontendEnneagramResultCardusesString(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 ofdominant_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 viaacts_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.mdfor 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(hasdeleted_attimestamp). 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.
- Belongs to a
- 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 viaacts_as_paranoid.
- Constraints:
- Check constraint:
elapsed_seconds BETWEEN 1 AND 86400 - Partial unique index on
[user_id, puzzle_date]wheredeleted_at IS NULL(one solve per user per day) - Unique index on
public_id
- Check constraint:
- 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. Returnsnilif 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(hasdeleted_attimestamp). - Associations:
- Belongs to a
User. - Belongs to a
CriticalPathPlay(optional FK — nil if the award is unrelated to a puzzle solve).
- Belongs to a
- 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 (> 0via 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 associatedCriticalPathPlayrecord.nilif 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 viaacts_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, anddeleted_atfor efficient filtering and cascade operations.
- Check constraint:
- Key Logic:
- An immutable, append-only ledger. Rows are never updated after creation.
- User's
stim_xp_totalis denormalized on theUseraggregate and kept in sync via theCriticalPath::AwardStimXpinteraction (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).
- Belongs to a
- Key Fields:
status—"pending","accepted", or"blocked".accountability_partner(boolean, defaultfalse) — whether this ally pair has an active partnership.accountability_partner_since(datetime) — when the partnership was established.accountability_partner_requested(boolean, defaultfalse) — whether a partner request is pending.accountability_partner_requested_by_id(bigint) — user ID of the requester.mutual_streak_count(integer, default0) — current mutual check-in streak.longest_mutual_streak(integer, default0) — 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 byGamification::AwardPartnerBonus.
- Key Scopes:
accepted— allies withstatus: 'accepted'.with_active_partnership— accepted allies withaccountability_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(hasdeleted_attimestamp). - Key Associations:
- Belongs to an
inviter(User).
- Belongs to an
- Key Fields:
token(string, unique) — 43-character URL-safe base64 token (generated viaSecureRandom.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 withstatus: 'pending'andexpires_at > Time.current.
- Key Methods:
expired?— returns true ifexpires_atis 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::CreateAllyInvitebefore creating a new record. - When accepted via
acceptAllyInvitemutation, thestatustransitions to"accepted"and the underlyingUserAllyrelationship (or upgrade if existing) is created. - Revocation via
revokeAllyInvitemutation setsstatus: 'revoked'and prevents further acceptance.
- Tokens are generated before validation (
- 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
allyInvitePreviewquery, 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(notPublicRecord— nopublic_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, viasender_id) — who sent the nudge.
- Belongs to a
- Key Fields:
user_ally_id(bigint, not null) — the partnership this nudge belongs to.sender_id(bigint, not null) — foreign key tousers; thesender_idin 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_nudgeshas nodeleted_at(see Soft Deletion above), so there is noWHEREclause 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 differentsender_id(the other direction) is allowed. - Indexes on
sender_idanduser_ally_idindividually, for GDPR cascade and lookup queries.
- Composite unique index on
- Key Logic:
PartnerNudge.sent_today?(user_ally:, sender:)— the single owning method for the rule, called by bothSocial::SendPartnerNudge's guard and thenudge_sent_todayGraphQL field (user_queries.rb), so the two can never disagree.Social::SendPartnerNudgecreates a row inside the same transaction as itsUserAllyupdate, and rescuesActiveRecord::RecordNotUniquefrom the unique index as the authoritative belt-and-braces guard against a race between two concurrent requests from the same sender — the same shape asStimXpLedger/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 (returnnull, not an error) when the caller isn't a member (PRIVACY-2).teamSettings/teamHomeback theTeamSettingsView(N7) andTeamHomeView(N13) aggregate reads. - Mutations:
startTeamCheckout,adjustTeamSeats,transferTeamBillingOwnership,cancelTeamSubscription,createTeamInvite,revokeTeamInvite,acceptTeamInvite,promoteTeamMember,removeTeamMember,joinSubCommunity,leaveSubCommunity,createSubCommunity,archiveSubCommunity,unarchiveSubCommunity,setDefaultSubCommunity,submitTeamPulse— registered inapp/graphql/types/mutation_type.rb, resolved byapp/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(hasdeleted_attimestamp). - Key Associations:
- Belongs to a
billing_owner(User). - Has one
subscription(TeamSubscription,dependent: :destroy). - Has many
memberships(TeamMembership,dependent: :destroy); has manymembers(User) throughmemberships. - Has many
communities(dependent: :nullify— aCommunitysurvives 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).
- Belongs to a
- Key Fields:
name(string, not null)slug(string, not null, unique) — used in Team-facing URLs.
- Usage: Created at Team checkout (Phase 2+).
Community#teamis nullable —nilmeans 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(haspublic_id, noacts_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.
- Belongs to a
- 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 oftrialing,active,past_due,grace,canceled(TeamSubscription::STATUSES). The whole lifecycle is Stripe-webhook-driven, not clock-driven:'active'is set byTeams::ProcessInvoicePaidoninvoice.paid, transitioningtrialing/past_due/grace→active(OBJ-1411);'past_due'is set byTeams::ProcessInvoicePaymentFailedoninvoice.payment_failed, transitioningtrialing/active→past_dueand stampingcurrent_period_endto the failure moment (OBJ-1412). Only thepast_due → grace → canceledsweep (7+7 days) remains a daily job (Teams::PaymentFailedJob), keyed offcurrent_period_end;Teams::TrialExpiredJob's daily sweep is a safety-net Sentry alarm only, not a state writer, since OBJ-1412. Auto-cancellation callsStripeService.cancel_subscriptionbefore writingcanceledlocally, and skips (fails closed) if Stripe still reports the subscriptionactive.canceledis 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 viainvoice.paid - Seed data (BILLING-4, Phase 1): two
Planrows are seeded by theSeedTeamPlansmigration, following the same migration-based seeding pattern asSeedSupporterPlans:
Plan.slug | Name | Price | Interval |
|---|---|---|---|
teams_monthly | Teams Monthly | 700¢ ($7.00) / seat | month |
teams_annual | Teams Annual | 7000¢ ($70.00) / seat | year |
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.
- Belongs to a
- Key Fields:
role(string, not null, default'member') — one ofowner,admin,member,external_coach(TeamMembership::ROLES)joined_at(datetime, not null)
- Constraints: Partial unique index on
[team_id, user_id]wheredeleted_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 —nilmeans the entry is team-wide, not scoped to a sub-community). - Belongs to a
User.
- Belongs to a
- Key Fields:
scope(string, not null) — one ofteam_wide,sub_community(TeamLeaderboardEntry::SCOPES)period(string, not null) — one ofweekly,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]withnulls_not_distinct: true(Postgres 15) — one entry per user per scope/period even whencommunity_id/period_startareNULL.
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(hasdeleted_attimestamp). - Key Associations:
- Belongs to a
Team. - Belongs to a
Community(optional —nilmeans team-wide; see#team_wide?). - Belongs to a
created_by(User). - Has many
contributions(CollectiveGoalContribution,dependent: :destroy).
- Belongs to a
- Key Fields:
name(string, not null),description(text, nullable)target_value(integer, not null,> 0)target_metric(string, not null) — one ofcheck_ins,milestones_completed,members_active_days,custom(CollectiveGoal::TARGET_METRICS). All three non-customvalues are aggregated (OBJ-1834) — seeevents_contributedbelow and Collective Goal Contribution Credit.aggregation_window_start,aggregation_window_end(datetime, nullable)
- Key Logic:
#team_wide?returnstruewhencommunity_idisnil.
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).
- Belongs to a
- Key Fields:
events_contributed(integer, not null, default 0,>= 0) — incremented by whichever job handles the contribution'starget_metric(Teams::CollectiveGoalContributionUpsertJobforcheck_ins/milestones_completed,Teams::CollectiveGoalActiveDayUpsertJobformembers_active_days), fired off the same check-in/goal-event call sites asTeams::TeamLeaderboardUpsertJob, never inline on the personal check-in path.check_insincrements via a single atomicUPDATE ... SET events_contributed = events_contributed + 1;milestones_completedandmembers_active_daysadditionally claim a row inCollectiveGoalContributionCreditfirst, so a milestone toggled complete → incomplete → complete or a backdated/retried check-in only credits once (OBJ-1834). Collective goals created onmilestones_completed/members_active_daysbefore OBJ-1834 shipped are not backfilled — they count from ship-forward only.opted_in_at,opted_out_at(datetime, nullable) — opt-out setsopted_out_atrather than destroying the row (same nullable-timestamp idiom asteam_memberships.leaderboard_opted_out_at), soevents_contributedhistory survives a later re-opt-in.
- Key Scopes:
.active(opted_out_at: nil) — contributions currently counted toward the goal; used byCollectiveGoalPresenterand the contribution-upsert job. - Key Logic:
#opted_out?returnstruewhenopted_out_atis 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 asCollectiveGoalContribution— noPublicRecord, noacts_as_paranoid; never soft-deleted) - Key Associations:
- Belongs to a
CollectiveGoalContribution.
- Belongs to a
- Key Fields:
dedupe_key(string, not null) —"milestone:<milestone_public_id>"formilestones_completed, or"active_day:<YYYY-MM-DD>"(member's own timezone) formembers_active_days.
- Constraints: Unique index on
[collective_goal_contribution_id, dedupe_key]. A credit is claimed viaINSERT ... ON CONFLICT DO NOTHING;events_contributedincrements 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(hasdeleted_attimestamp). - Key Associations:
- Belongs to a
Team. - Belongs to an
invited_by(User). - Belongs to an
accepted_by(User, optional).
- Belongs to a
- Key Fields:
code(string, not null, unique)email(string, nullable) —nilmeans an open link invite.team_role(string, not null, default'member') — one ofadmin,member(TeamInvite::TEAM_ROLES;owneris not invitable).preselected_community_ids(bigint array, default[], not null)expires_at(datetime, not null)max_uses(integer, default 1, nullable —nilmeans unlimited)used_count(integer, not null, default 0,>= 0)status(string, not null, default'pending') — one ofpending,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'sTeamMembership#rolefor the team and checks it against theGRANTSmap:view_team_data—owner,admin,member,external_coach(anyone seated on the team)manage_team—owner,admin(roster, sub-communities, collective goals)manage_billing—owneronly (checkout, seat changes, ownership transfer, cancellation)- A caller with no membership on the team (
membership_rolereturnsnil) fails everycan?check.
- Wired everywhere: every
Teams::*interaction (app/interactions/teams/*.rb— invites, membership changes, sub-community management, billing) callspolicy.can?(...)in anauthorized?guard and fails withcode: :forbiddenwhen it returnsfalse. The read side (Resolvers::TeamQueries#team_settings/#team_home) enforces membership via the PRIVACY-2team_scopedgate rather thanTeamAccessPolicydirectly, 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 asTeamLeaderboardEntry/CollectiveGoalContribution— noPublicRecord, noacts_as_paranoid) - Key Associations:
- Belongs to a
Team. - Belongs to a
User.
- Belongs to a
- Key Fields:
score(integer, not null) — 0–10, validated viainclusion: { 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_periodon[team_id, user_id, period]— one response per member per quarter.periodvalidation additionally enforcesuniqueness: { 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::SubmitTeamPulsedeliberately omitsscore/commentfrom its return payload (TeamPulseConfirmationTypeexposes onlyperiod) so the client never receives a receipt of what was submitted. - Usage: Written by
Teams::SubmitTeamPulse(app/interactions/teams/submit_team_pulse.rb), guarded byTeamAccessPolicy#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 theteamPulseEligibility(teamId:)GraphQL query — see GraphQL API Reference § Team Pulse Survey. Live in production since theteams_enabledflag 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: gainsbelongs_to :team, optional: true(nullable FK —nilmeans a free/public community, unaffected by Teams) andis_default_for_team(boolean, not null, defaultfalse).PaymentRecord: gainsbelongs_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.
- Generates a unique
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 viaacts_as_paranoid
- Usage: Lookup table referenced by
UserFeedItemandCommunityFeedItemvia foreign keyfeed_kind_id. Populated via database seeds; never created dynamically. - Current kinds (defined in
db/seeds/production.rb):join— User joined the communitycompleted— User completed a goalupdated— User posted a goal update or milestoneadd— User added a goalencouragement— User received an encouragementbadge— User earned a badgegeneral— General activity feed entrycomment— User received a commentchallenge_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
- Has many
- Key Fields:
name,role_key(unique) — identityskill_refs(string array) — skill names loaded into system promptmcp_servers(string array) — scoped tool access (e.g.,['github', 'sentry'])task_prompt(text) — instructions for the agentschedule_cron(string) — cron expression for automatic runsautonomy_level(string) —shadow,semi_autonomous, orautonomousmonthly_budget_cents(integer, default: 3000) — monthly spend cap in cents. Resets on the first of each calendar month.model_preference(string) — LLM model routing preferencepost_filter_skill(string) — brand-voice filter skill referencemax_turns(integer, default: 30) — max conversation turns per runoutput_kind(string) — expected artifact typepromotion_confirmed_at(datetime) — tracks operator-confirmed autonomy promotions
- Computed properties (not stored columns):
current_month_cost_cents— Sum ofcost_centsacross allAiRuns 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_cents—monthly_budget_cents - current_month_cost_cents. Indicates how much budget remains in the current calendar month.budget_exhausted?— Boolean predicate:trueifcurrent_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::TriggerRuninteraction (called before enqueueing a new run) - Check: Calls
employee.budget_exhausted?(compares current month's summedAiRuncosts againstmonthly_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 /runsroute handler inagent_runner/src/routes/runs.ts - Check: Guards against stale data by re-validating
currentMonthCostCents >= monthlyBudgetCentswhen 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
runAgentfunction inagent_runner/src/agent/runner.ts(budget cap computed at lines 23-28, wired viamaxBudgetUsd: runBudgetUsdat line 69) - Mechanism: Each run's budget is capped to
Math.min(MAX_RUN_BUDGET_USD, remainingMonthlyUsd), whereremainingMonthlyUsd = (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_monthand passed to Layers 2 and 3 ascurrentMonthCostCents. - Toggling
employee.active = falseby Layer 1 does not auto-reset on month boundary; it requires manual operator action to setactive = true.
AiRun
Records execution lifecycle and cost tracking for each agent run.
- Inherits from:
PublicRecord - Key Associations:
- Belongs to
AiEmployee - Has many
AiArtifacts
- Belongs to
- Key Fields:
status(string) —queued,running,succeeded,failedtriggered_by(string) —scheduleormanualstarted_at,finished_at(datetime) — execution timestampsprompt_tokens,completion_tokens(integer) — token countscost_cents(integer) — run cost from LiteLLMrun_log(jsonb) — execution details including tool callserror_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
- Belongs to
- Key Fields:
kind(string) —draft_post,code_patch,email_template,report,recommendation,ui_designtitle(string),payload(jsonb) — artifact contentapproval_status(string) —pending,approved,rejected,auto_rejectedrejection_reason(text) — reason if rejectededit_distance(float) — Levenshtein ratio if edited before approvaldelivered_at(datetime),delivery_metadata(jsonb) — delivery trackingreviewed_by(integer),reviewed_at(datetime) — reviewer infoclaimed_by(integer),claimed_at(datetime) — set by theclaimAiArtifactmutation (v4.8, OBJ-1562) when an operator takes a pending artifact for themselves without resolving it;approval_statusstayspending.claimed_atis 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 toDesignAssetobjects 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
- Belongs to
- 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
- Has one attached image via
- Key Fields:
viewport(string, nullable) — one ofmobile,tablet,desktoptheme(string, nullable) — one oflight,dark
- Constraints:
VIEWPORTS = %w[mobile tablet desktop],THEMES = %w[light dark] - Usage: Each
DesignAssetrecord represents a single screenshot from the Playwright renderer CLI (npm run desi:render). A single mockup HTML file rendered across 3 viewports × 2 themes = 6DesignAssetrecords. URLs (fromActiveStorageUrl.for(asset.image)) are collected in the parentAiArtifact'spayloadand 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 viaTime.use_zone(user.timezone_or_default) { Date.current }. TheVERSIONconstant (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 toAi::Fallbacks::Insight.coach_homeifgenerate_synopsisreturnsnil. - Error path: any
StandardErroris rescued, logged, and captured in Sentry. ReturnsAi::Fallbacks::Insight.coach_homewithgenerated: false. Interaction does notresult.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::DailyTriggerEventwithsource: :daily_loadandgenerated: <bool>in all three paths. Event is captured via PostHog asai_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 textgenerated: 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.vueonMounted— fires beforefetchInsight({ page: 'coach-home' }). The mutation result is not consumed directly by the component; theuseAiInsightsread 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 asAi::EnsureTodaysCoachMessage— overwrites in place - Trigger: called from
AiCoaching::GenerateFollowUpCoachMessageJob, which is enqueued byAi::Subscriberson everygoal_tracking.habit_checked_inevent - Signal check: evaluates
User#required_checkins_complete_today?first; returns immediately (no-op) whenfalse - 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 (orAi::Fallbacks::Insight.coach_homeon nil) to cache withsource: 'completion_followup', 24-hour TTL - Error path: any
StandardErroris 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::DailyTriggerEventwithsource: :completion_followup,generated: <bool>, andsignal_flipped_in_this_call: true(alwaystrueat the call site — the re-flip no-op guard returns before this point). On error, fires withgenerated: false(nosignal_flipped_in_this_callproperty)
Ai::Subscribers
Boot-time domain event subscriber registry for the AI coaching context.
- Location:
rails_api/app/interactions/ai/subscribers.rb - Registration:
Ai::Subscribers.registeris called fromrails_api/config/initializers/domain_event_subscribers.rbinsideafter_initialize. Idempotent via@registeredguard - Subscriptions:
goal_tracking.habit_checked_in→ resolves user byevent.payload[:user_id](public_id), enqueuesAiCoaching::GenerateFollowUpCoachMessageJobwith integeruser.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?(AtRiskCadenceconcern), 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 (seeCoachTriggerDispatchabove) - Precedence guard: does not overwrite a same-day
completion_followuporat_risk_goalcache entry; may overwrite adaily_loadsynopsis - Write path: calls
Ai::CoachService#generate_at_risk_nudge(user, goal); writes result (orAi::Fallbacks::Insight.coach_homeon nil) to the samecoach-homecache key withsource: 'at_risk_goal', 24-hour TTL; records aCoachTriggerDispatchrow - Error path: any
StandardErroris 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::DailyTriggerEventwithsource: :at_risk_goalandgenerated: <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-homeexcluded since Phase 117 (v3.12.5):coach-homewas removed fromAi::Coaching::DailyInsightPackKey::PAGES. Thecoach-homesurface is event-driven viaAi::EnsureTodaysCoachMessageandAi::EnsureCompletionFollowUpCoachMessage.- Cache key format:
ai:insight:v1:<user_public_id>:<page>:<YYYY-MM-DD>using the UTC date (not user-local). Onlycoach-homekeys 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_generatedevent per (user, page) pair, withsource: '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-homeread: the resolver reads thecoach-homecache 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 resultstatus(enum:pending/succeeded/failed, defaultpending)result(jsonb, default{}) — the handler's success payload. Exposed over GraphQL as aresultJsonstring field rather than a payload-type union (Q-1) — each frontend composable parsesresultJsoninto its own already-typed shapeerror_code,error_message(string / text) — populated on failurerequest_digest(string, nullable) — correlation aidcompleted_at(datetime) — set on both success and failure
- Constraints:
acts_as_paranoid; unique index onpublic_id; index on(user_id, created_at) - Retention:
AiRequest::RETENTION_WINDOW= 7 days (Q-2). Theretention_eligiblescope selectssucceeded/failedrows withcompleted_atolder than the window;pendingrows are never touched by it, regardless of age. - GraphQL surface:
aiRequest(id:)query and theaiRequestUpdatesubscription — see GraphQL Reference § Async AI Delivery and API Reference § Real-time Subscriptions. - GDPR erasure: hard-deleted (
AiRequest.with_deleted.where(user_id: ...).delete_all) viaGdpr::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.kindinAi::AiRequestHandlerRegistry, calls the handler with theAiRequest, and persists the outcome —succeeded+resulton success,failed+error_code/error_messageon any raised error — then broadcasts onSubscriptions::AiRequestUpdateeither 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 resultHashto persist on success or raising to signal failure.handler_forraisesAi::AiRequestHandlerRegistry::UnknownKindfor an unregistered kind (surfaced aserror_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 setsParanoia.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_attimestamp is set instead. default_scopefilters 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)