Phase 7 — Teams (master hub)
The single navigable index tying Teams spec, design, execution, and components together.
Status: ✅ Shipped — GA live in production on tag v4.0.3 (2026-07-15). See milestone narrative and v4.0-teams-v1-MILESTONE-AUDIT.md. v4.19 Teams V1 Wiring Completion (billing dead-ends, in-app collective-goal creation, sub-community join/leave/switch, fresh-member CTA + honest gating) merged to master via promotion PR #1956 (OBJ-2012) — see v4.19 milestone narrative and v4.19-teams-v1-wiring-MILESTONE-AUDIT.md. v4.35 Phase 1 (room feed, OBJ-1825) shipped PR #2210 — see § Honest-gating pass below. Phase: 7 (Apr 2027+) Owner: Josh Lockhart Last updated: 2026-08-21
1. Overview & thesis
Teams is Objectuve's first B2B revenue stream. It introduces a paid, private, multi-community workspace for groups who already share a goal — coaching clients, corporate wellness cohorts, school groups.
Lead ICP: Coaching groups, 5–20 seats. Single decision-maker, highest willingness to pay, lowest compliance bar. No SOC 2, no SSO required for V1.
The thesis: A Team owns billing and seats. It contains one or more private Communities that members self-select into. Small coaching cohorts run with a single default Community (familiar single-group shape). Larger teams run multiple sub-communities — "Running Crew," "Meditation Circle," "Q2 Sales Cohort."
This is not a Slack competitor. It is the accountability layer for groups who share a goal, capped at ~10 min/day per the anti-social-app philosophy.
Revenue targets
| Horizon | Paying teams | Avg seats | MRR |
|---|---|---|---|
| 12 months post-GA (Apr 2027) | 10–30 | 6–10 | $2K–$8K |
| 3 years (Apr 2029) | 200–800 | 8–12 | $30K–$100K |
The 12-month target is intentionally modest: inbound and word-of-mouth only, no outbound sales budget.
2. Source-of-truth map
| Document | Description |
|---|---|
./phase-7-teams-prd.md | Full PRD: business case, ICPs, pricing decision, data model decision, data-boundary contract (§6), V1 deep spec (§7), V1.1+ shape-only (§8), success metrics (§10), rollout strategy (§11), open decisions (§12), sprint sequencing (Appendix C) |
./phase-7-teams-surfaces-and-wireframes.md | Surface inventory: N1–N35 new surfaces, M1–M24 modified surfaces, ASCII wireframes, sprint→surface sequencing table, dual-context user model (§5) |
./teams-native-platform-scope.md | Native (iOS) platform boundary — member-first native surface, permanent web-only management/billing boundary, hide-vs-show-disabled decision, 3.1.1-safe copy |
This hub is the index that ties spec, design, execution, and component inventory together. When spec and hub disagree, the PRD is authoritative.
3. Material decisions
Resolved
| Decision | Resolution |
|---|---|
| Pricing | $7/user/month or $70/user/year (~17% annual discount), 14-day free trial — no card required to start trial. Below Slack ($7.25) anchor; above hobby-tool perception floor. |
| Data model | Separate Team aggregate that has_many :communities (Option C). Existing Community model gains optional team_id FK and is_default_for_team boolean. Small teams get a single auto-provisioned default community; the multi-community surface hides until a second community is created. |
Action items from pricing resolution: update docs/product/roadmap.md line ~989, docs/product/pricing-philosophy.md Teams section, docs/product/revenue-projections.md. Seed one Plan row per tier (monthly, annual) in Rails.
Open decisions (PRD §12)
Statuses to confirm at epic kickoff. Status below is beta-confirmed (verified directly against shipped Phase 1–6 code as of this update, 2026-07-07) vs. still deferred to a later version.
| # | Question | Recommendation | Owner | Status |
|---|---|---|---|---|
| 1 | Final Teams price | $7/mo, $70/yr, 14-day trial | Josh | ✅ Confirmed — teams_monthly/teams_annual Plan rows (price_cents: 700/7000), TeamSubscription::TRIAL_DAYS = 14 |
| 2 | Data model | Separate Team aggregate, Team has_many :communities | Josh | ✅ Confirmed — shipped Phase 1 |
| 3 | Bulk CSV invite — V1 or V1.1? | V1.1 | Josh | ⏸ Deferred — teams_bulk_invite flag exists but only gates single email invites in V1; no CSV surface yet |
| 4 | Mood-trend aggregate visible to admins? | No (strict §6; revisit at SOC 2 phase) | Josh | ✅ Confirmed — admin_teams_monitoring resolver exposes only seat count/status/last-active, no goal/mood/journal data |
| 5 | Annual discount magnitude | 17% ($70 vs. $84) | Josh | ✅ Confirmed — 7000 vs 12 × 700 = 8400 cents (16.7% ≈ 17%) |
| 6 | Trial requires card? | No | Josh | ✅ Confirmed (re-confirmed 2026-07-13 after a brief reversal scare — see OBJ-1401) — StripeService.create_team_checkout_session sets payment_method_collection: 'if_required' and subscription_data.trial_settings.end_behavior.missing_payment_method: 'create_invoice'. Trial starts card-less; an owner adds a card mid-trial via the team billing portal (startTeamBillingPortal mutation), or the trial-end invoice settles once a card is added. See GraphQL Reference § Teams Billing |
| 7 | Max team size at launch | 200 | Josh | ✅ Confirmed — StartTeamCheckout/AdjustTeamSeats both enforce seat_count.between?(1, 200) |
| 8 | Timezone handling for weekly leaderboards | Team-level, default America/New_York | Josh | ✅ Confirmed — teams.timezone column, default "America/New_York" |
| 9 | Refund policy | 7-day, manual via Stripe dashboard | Josh | ✅ Confirmed — Teams::CancelTeamSubscription::REFUND_WINDOW_DAYS = 7; no automated refund method exists. See the Teams billing runbook |
| 10 | Beta participant compensation | Free during beta + 50% discount locked 6 months post-GA | Josh | ✅ Confirmed — shipped Phase 6, Teams::GrantBetaParticipantCompensation (DISCOUNT_PERCENT = 50, DISCOUNT_LOCK_MONTHS = 6) |
| 11 | Default sub-community auto-join behavior | Auto-join default on accept; browser only when ≥1 non-default exists | Josh | ✅ Confirmed — AcceptTeamInvite#auto_join_default_community |
| 12 | Sub-community archive grace period | 30 days read-only then paranoia-deleted | Josh | ✅ Confirmed — Teams::ArchiveSubCommunity::ARCHIVE_WINDOW = 30.days |
| 13 | External Coach seat-billable? | Free addon, max 1 per team in V1.2 | Josh | ⏸ Deferred — no External Coach role/billing code shipped yet, on track for V1.2 |
| 14 | Cap on sub-communities per team | 10 in V1 | Josh | ✅ Confirmed — Team::MAX_SUB_COMMUNITIES = 10 |
| 15 | Can members create sub-communities? | Admins only in V1 | Josh | ✅ Confirmed — Teams::CreateSubCommunity is admin-only |
| 16 | Sub-community discovery policy | Mix per sub-community: open (default) or request-to-join toggle | Josh | ✅ Confirmed — join_policy validates to open/request, default open |
4. Data model map
Full migration sketch in PRD §5.
New tables
| Table | Purpose |
|---|---|
teams | Aggregate root — owns billing, seats, and a directory of one or more Communities. Carries public_id, name, slug, billing_owner_id. Soft-deleted via acts_as_paranoid. |
team_subscriptions | Links Team ↔ Plan ↔ Stripe. Tracks status (trialing/active/past_due/grace/canceled), seat_count, trial_ends_at, current_period_end. |
team_memberships | The seat — join record between a User and a Team. Carries role (owner/admin/member/external_coach). Unique per (team_id, user_id) while not soft-deleted. |
team_leaderboard_entries | Denormalized leaderboard rows per (team, community, user, period). Period: weekly/monthly/all_time. Recomputed via Sidekiq on every points-earning event. |
collective_goals | Team- or sub-community-scoped shared goal. Separate from personal Goal to keep the personal surface clean. |
collective_goal_contributions | Opt-in contribution record linking a User to a CollectiveGoal via their personal goal. Tracks events_contributed. |
team_invites | Invite record — link or email-targeted. Carries code, email (nullable), team_role, preselected_community_ids, expires_at, max_uses, status. |
team_pulse_responses | Quarterly NPS-style pulse survey response (score 0–10, optional comment, period e.g. "2026-Q3"). Unique per (team, user, period). Net-new in Phase 7 GA (OBJ-1209/1210/1211) — Phase 6 never actually shipped this despite the original plan assuming a carry-forward. Objectuve-internal telemetry — no team-admin read path. See Data Models § Team Pulse Response. |
Extensions to existing tables
| Table | New columns |
|---|---|
communities | team_id (nullable FK — null for free/public communities), is_default_for_team (boolean, default false) |
plans | Add rows: teams_monthly, teams_annual |
payment_records | team_subscription_id (nullable FK) |
5. Data-boundary contract (PRD §6)
This section is brand-defining. Team admins see only team-scoped activity. Personal goals, mood logs, journal entries, and coach conversations are never visible to admins, never exported, never aggregated. Full stop.
Visibility table
| Role | CAN see | CANNOT see (ever) |
|---|---|---|
| Team Owner / Team Admin | Team-wide membership, seat count, billing; aggregate activity across all sub-communities; sub-community-level summaries; team-tagged goal contribution counts; last-active date (day only); leaderboard points earned within scope | Message-level activity inside sub-communities they didn't join; personal goals outside team scope; mood logs; journal entries; Coach conversations; lifetime XP/streaks; friend/ally graph; IP/device/location |
| Sub-Community Lead | Full activity within their sub-community (posts, members, leaderboard, collective goals) | Cross-community visibility outside their sub-community |
| Team Member | Full activity within sub-communities they joined | Sub-communities they didn't join; other members' personal data |
| External Coach | Read-only within explicitly granted sub-communities | Team-wide view; personal data of members |
Enforcement mechanisms
TeamAccessPolicy— Rails service object centralizing all "can user X read/mutate Y in Team T" checks. Every read and mutate path goes through it.TeamScopedView— Vue composable/mixin that all team-data widgets must use. A Vitest contract test fails if a team-data widget renders without it.- Team pulse survey responses (
team_pulse_responses) — Objectuve-internal telemetry only. No team-admin read path exists in scope: no query joins pulse responses into any team-data aggregate, andsubmitTeamPulse's response payload deliberately omits the submitted score/comment (no receipt echoed to the client). This is the concrete implementation of the "feel surveilled" zero-tolerance anti-metric from the Success metrics summary — the trust line rendered inTeamPulseSurvey.vuestates plainly that responses go to Objectuve, "never your team admin."
Offboarding — "Former member"
When a member leaves or is removed: team-scoped historical contributions stay attributed as "Former member" (anonymized) for leaderboard integrity. Their personal account, goals, and data are entirely untouched. They retain a "Previously a member of [Team name]" note on their own profile only.
CSV export scope
Admin CSV exports include: member display name, joined-at, last-active day, team-goal contribution counts, team leaderboard points (period-scoped), custom badges earned within team. Excluded: real name, email, personal goal titles, lifetime XP, lifetime streaks.
6. GraphQL surface
All mutations and queries require an authenticated session (the top-level teams_enabled flag was retired at GA — see §7) and the relevant kill-switch flag, where one applies. All mutations are authenticated via SessionToken header; admin-only mutations require Team Owner/Admin TeamMembership role checks via TeamAccessPolicy.
Note: The
idfields shown in the type sketches below denote thepublic_id-backed GraphQL identifier per the repo's PublicRecord convention — never the raw integer primary key.
Billing & provisioning
mutation StartTeamCheckout($input: StartTeamCheckoutInput!) # returns checkoutUrl, errors
mutation StartTeamBillingPortal($input: StartTeamBillingPortalInput!) # returns portalUrl, errors — owner-only, mid-trial add-a-card path
mutation AdjustTeamSeats($input: AdjustTeamSeatsInput!)
mutation TransferTeamBillingOwnership($input: TransferTeamBillingOwnershipInput!)
mutation CancelTeamSubscription($input: CancelTeamSubscriptionInput!)
type TeamSubscriptionType { id, status, seatCount, currentPeriodEnd, trialEndsAt, plan, billingOwner }Owner-facing billing dead-ends wired (v4.19, OBJ-1828)
Two owner-facing billing CTAs shipped to GA (v4.0.3) advertising an action and firing nothing (OBJ-1808 findings #2/#4). Both now wire through the existing mutations above — no new mutation or interaction was needed.
Seat-cap "Add seats." SeatCapBlockerModal's primary action now opens an AddSeatsConfirmSheet (seat delta, per-seat price, new total) that fires adjustTeamSeats with an absolute seatCount = seatsUsed (TeamMembersTab.vue). AdjustTeamSeats updates the Stripe subscription item quantity directly (Stripe::Subscription.update, no proration_behavior override — Stripe's default, create_prorations, applies). Confirmed against Stripe's proration docs: this does not charge the card today — the prorated difference is added as an invoice item to the team's next invoice at their existing renewal date, it isn't billed immediately. That's why the confirm sheet's copy states the resulting total ("Your plan becomes $T/mo. Change or cancel anytime.") rather than a same-day-charge claim — a date-based "billed today, prorated to {date}" framing that was drafted during design would have misstated Stripe's actual behavior.
Grace/canceled resubscribe. A canceled or grace-period team's owner can now complete a new Stripe Checkout from the TrialStatusBanner (TeamHomeView, TeamSettingsView) or a new resubscribe panel on TeamBillingTab, all routed through a shared useTeamResubscribe composable calling startTeamCheckout with the existing teamId. Teams::StartTeamCheckout already accepted an existing team (no guard on subscription status beyond owner authorization), so no new backend interaction was built. Seat count is explicitly preserved on resubscribe: the composable is called with the team's current subscription.seatCount, and the interaction's resolved_seat_count only falls back to its DEFAULT_SEAT_COUNT (5) when there's no existing team subscription at all (i.e., a genuinely new team) — an existing team's seat count is never silently reset.
Resubscribe resumes immediately-billed, no new trial (v4.19, OBJ-1835). Resubscribing reuses the same checkout → Teams::ProvisionTeamSubscription webhook path as a brand-new signup, but the two are no longer provisioned identically. A resubscribe checkout (existing team, carries objectuve_team_id in Stripe metadata — the same discriminator start_team_checkout.rb:82 already sets) grants no trial: StripeService.create_team_checkout_session omits subscription_data entirely when that metadata key is present, so Stripe collects payment and bills immediately, and Teams::ProvisionTeamSubscription#provision_subscription sets status: 'active', leaves trial_ends_at nil, and derives current_period_end from a real Stripe lookup (StripeService.current_period_end) rather than a trial-window date. A brand-new team's checkout is unaffected — it still provisions trialing with the full TRIAL_DAYS-length trial. This closes a revenue-leak/trial-farming gap where a previously-paying team that churned could resubscribe into another free trial; Josh confirmed the immediately-billed behavior on OBJ-1827. Verified in provision_team_subscription_spec.rb and stripe_service_spec.rb.
⚠️ Config existing ≠ config wired. A GCP Secret Manager entry showing up in a Cloud Run revision's env is not proof the app reads it — that only happens if a migration or task actually copies the value onto
Plan#stripe_price_id.STRIPE_PRICE_TEAMS_MONTHLY/STRIPE_PRICE_TEAMS_ANNUALwere live inenkidu-api-staging's Secret Manager config for a period with no code path anywhere in the repo that read them —grep -rn STRIPE_PRICE_TEAMSreturned zero app-code hits — soteams_monthly/teams_annualkept a nilstripe_price_iddespite the secrets looking "configured" (flagged as a RED P4 finding in Dave's T4b pre-flight audit, fixed under OBJ-1224).The wiring (as of
stripe:wire_prices, OBJ-1339, 2026-07-11) — the two migrations referenced in earlier versions of this note are retired; read on before touching them.
rake stripe:wire_pricesis the sole supported wiring mechanism. It is slug-scoped (stripe:wire_prices[teams_monthly, teams_annual]wires only the givenPlanslugs — out-of-scope slugs are never even read) and idempotent, and runs a cross-mode guard before writing anything: every price is retrieved from Stripe and itslivemodecompared against the activeSTRIPE_SECRET_KEY's mode, so a test-mode price can never land on a row a live key reads, or vice versa. It fails loudly (non-zero exit naming the var/slug) instead of the earlier silentENV.fetch(..., existing_value)fallback.- Both original migrations —
20260708000000_wire_test_mode_stripe_prices.rband20260708130000_retry_wire_test_mode_stripe_prices.rb— are now permanently neutralized to explicit no-ops. They used to write all fourPlanrows unconditionally with no cross-mode guard — a second, unguarded write path that would have bypassed the guard above and fired automatically on the next deploy carrying anySTRIPE_PRICE_*var. The first of the two already burned once on staging this way (see the history below); rather than leave a second unguarded path standing, bothup/downbodies are now single-line comments. They are not a "fix it later" TODO — do not un-neutralize them as a troubleshooting step. This is also why the migration-first design was retired in favor of a rake task: a migration only ever executes once per recorded version, so if it runs before itsSTRIPE_PRICE_*secret is wired,ENV.fetchsilently falls back tonil, the migration still records as applied, and fixing the env var afterward does not make it re-apply — a new migration version becomes the only way to retry. A rake task has no such recorded-state trap.- Production execution is a dedicated, non-auto-executing Cloud Run job. The Rails app never reads
STRIPE_PRICE_*at request time —Teams::StartTeamCheckoutreadsPlan#stripe_price_idfrom the database — so these vars are a wiring-time input only, wired into exactly one place: theenkidu-stripe-wire-pricesCloud Run job (.github/workflows/production.yml). The job is created/updated on every production deploy but is never executed automatically (nogcloud run jobs executestep for it) — it must be run manually, and only after a deploy has seeded theteams_monthly/teams_annualPlanrows (20260704000010_seed_team_plans.rb) — see Deployment § Stripe price wiring for the exact command and the--argscomma-splitting gotcha.Teams::StartTeamCheckout#plan_purchasable?fails fast with an explicit error — "Teams checkout is not yet available for this plan — Stripe pricing has not been configured." — whenstripe_price_idis still blank, instead of a generic "Payment error" surfacing three layers down insideStripeService.create_team_checkout_session.History — this took two attempts. The migration-based wiring (points 1–2 above, as originally written) shipped, was found unwired in production, got a wiring PR (#1448) that correctly tightened
validate-secrets.shto catch aSTRIPE_PRICE_*var wired into zero blocks — but shipped that lint tightening in the same PR as the demand it enforced, which failed the very next staging deploy (both workflows share one lint run) and forced a revert 5 minutes after merge. The corrected approach lands the wiring (a dedicated job, not the shared rails blocks) and any lint change together, never the lint ahead of the wiring — see Deployment § Pre-deploy secrets lint for the general rule this established.⚠️ Live/test-mode secret split is by name, not by environment scoping — and the direction is the opposite of the P6 gotcha below. Staging and production share one GCP Secret Manager project (
enkidu-488723) with noenvironment:scoping (same root cause as P6). ForSTRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET(P6), the fix gave staging a-staging-suffixed secret and left production on the original name. ForSTRIPE_PRICE_TEAMS_*, it's reversed: staging keeps the originalstripe-price-teams-monthly/stripe-price-teams-annualnames (test-mode), and production reads new-live-suffixed secrets (stripe-price-teams-monthly-live/stripe-price-teams-annual-live, live-mode). Don't assume a single "which side gets the suffix" convention across secrets — check which name each workflow actually references. Never put a live-mode price ID into the unsuffixed secrets — staging reads those, and doing so would make staging checkout charge real cards.The lesson: when auditing a Stripe-pricing gap, confirm the actual read path (
grep -rn STRIPE_PRICE_TEAMSshould return app-code hits — a rake task, a migration, a service — not just infra config) before concluding a price id is provisioned or that a fix is still needed. Separately: a one-shot migration is the wrong tool for wiring a secret that might not exist yet at migrate time, and a lint that enforces wiring is the wrong thing to ship ahead of the wiring itself — both of those cost a real incident here.
⚠️ Config read correctly ≠ config scoped to the right environment. P4 above is "secret deployed but never read." This is its sibling failure class: secret deployed and read correctly, but it's the wrong environment's secret.
enkidu-api-stagingandenkidu-worker-stagingreferencedSTRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRETvia the same Secret Manager secret names as production (stripe-secret-key,stripe-webhook-secret) — no environment separation for Stripe at all, unlike Clerk's already-correctclerk-secret-key-dev(staging) vsclerk-secret-key(production) split. Staging was live-charging-capable using production'ssk_live_…key; it only failed closed by accident, because the Teams price ID secrets it also read were genuinely test-mode objects the live key couldn't see (flagged RED as P6 in Dave's T4b pre-flight re-audit, fixed under OBJ-1225).The fix: two new staging-only test-mode secrets —
stripe-secret-key-staging(sk_test_…) andstripe-webhook-secret-staging(whsec_…, paired with a genuine test-mode webhook endpoint registered against staging's URL) — withdeploy/service.staging.yamlanddeploy/worker.staging.yamlrepointed to them.deploy/*.production.yamlare untouched; production keepsstripe-secret-key/stripe-webhook-secret.The regression guard:
scripts/check-deploy-secret-drift.mjs, run by.github/workflows/deploy-secret-drift.ymlon every PR touchingdeploy/**, parses all fourdeploy/*.yamlmanifests and fails CI if anySTRIPE_*env var resolves to the samesecretKeyRef.nameon a staging file as on a production file. See Deployment § Staging silently read production's live-mode Stripe secrets by name collision for the full incident writeup.The lesson: "does staging read its own copy of this secret" is a distinct question from "does staging read a value for this secret at all" (P4's question). A secret existing in Secret Manager and resolving to a non-nil value in the app is not proof it's the right value for that environment — check whether the
secretKeyRef.namestaging points at is actually distinct from production's, the way Clerk's-devsuffix already does.
Invites & membership
mutation CreateTeamInvite($input: CreateTeamInviteInput!) # link or email invite
mutation RevokeTeamInvite($input: RevokeTeamInviteInput!)
mutation AcceptTeamInvite($input: AcceptTeamInviteInput!)
mutation PromoteTeamMember($input: PromoteTeamMemberInput!)
mutation RemoveTeamMember($input: RemoveTeamMemberInput!)
type TeamInviteType { id, code, email, teamRole, status, expiresAt }
type TeamMembershipType { id, role, joinedAt, user, subCommunities }Member activity sheet wired (Teams V1.1 backlog, OBJ-2300/OBJ-2301)
TeamMembersTab.vue's member ⋮ menu item "View activity" — gated · soon/disabled since the v4.19 Phase 4 honest-gating pass (§8) — now opens TeamMemberActivitySheet.vue, a read-only, owner/admin-only sheet backed by the new teamMemberActivity query. Shipped PR #2208. See GraphQL Reference § teamMemberActivity and § TeamMemberActivity for the full field list and arguments.
query teamMemberActivity(teamId: String!, memberId: String!): TeamMemberActivity # owner/admin only, null on any denial
type TeamMemberActivity { lastActiveDays, lastActiveLabel, periodStats, collectiveGoalContributions, subCommunityNames, optedOut }
type TeamMemberActivityPeriodStat { period, points, checkins } # points/checkins null when optedOutField list is exhaustive and contract-bound (§5 Data-boundary contract above): last-active (day granularity only), team points/check-ins per period (weekly/monthly/all-time), collective-goal contribution count, sub-community names. Never rendered: personal goals, mood logs, journal entries, coach conversations, lifetime XP/streaks, ally graph, device/IP/location, message content — enforced by the field list itself, not a client-side filter.
Numeric last-active restored, threshold reconciled (OBJ-2335, OBJ-2312 — resolved). TeamMemberActivityType now exposes a nullable lastActiveDays: Int (day granularity, sourced from Users::LastActivityTracker#days_since_active), so TeamMemberActivitySheet.vue renders the full 4-state scale Desi's UI-SPEC called for: fresh (0–1d), recent (2–13d), inactive (≥14d), and unknown (null — no activity recorded yet). Shipping this also fixed a real bug: lastActiveLabel previously returned "Active today" for any member under the 14-day threshold regardless of actual gap. OBJ-2312's original premise — a 7-day-vs-14-day inconsistency between isInactive and lastActiveLabel — was never a live code inconsistency; both fields always shared the same INACTIVE_AFTER constant. The real gap was that Desi's UI-SPEC assumed a 7-day boundary while the shipped threshold was 14; the reconciliation kept the shipped 14-day value and made it the one constant both the sheet's warning tier and TeamMemberRow.vue's isInactive boundary derive from, so the two surfaces can no longer disagree about the same member. Separately, the underlying source moved from user.updated_at (a housekeeping timestamp that bumps on any write and under-reports inactivity) to the app's canonical users.last_activity_date via Users::LastActivityTracker — the same service UserType already uses for "last active" — which shifts which members appear under the Members-tab "Inactive" filter on deploy. Shipped PR #2547.
Sub-communities
mutation JoinSubCommunity($input: JoinSubCommunityInput!)
mutation LeaveSubCommunity($input: LeaveSubCommunityInput!)
mutation CreateSubCommunity($input: CreateSubCommunityInput!)
mutation ArchiveSubCommunity($input: ArchiveSubCommunityInput!)
mutation SetDefaultSubCommunity($input: SetDefaultSubCommunityInput!)Member browse/join/leave/switch wired (v4.19, OBJ-1830)
joinSubCommunity/leaveSubCommunity shipped in Phase 3 (OBJ-1092) with zero frontend callers, and SubCommunitySwitcher's onSelectRoom/onBrowseAll handlers discarded their own emitted selections — a member could see their rooms but never actually join, leave, or switch one. v4.19 Phase 3 wires all three; no new mutation, interaction, or migration was needed (joinSubCommunity/leaveSubCommunity and CommunityType.isMember were pre-existing and unused — see GraphQL Reference § teamHome and § Sub-communities).
Switch is client-side only, no persistence (A1). TeamHomeView's room strip and SubCommunitySwitcher now hold activeRoomId in a local ref seeded once from teamHome.activeRoomId, so a later refetch() (e.g. after a join) doesn't stomp a switch the member already made that session. There is no SetActiveSubCommunity mutation and no membership column recording the active room — per Desi's UI-SPEC, persisting the switch is explicitly deferred to V1.1. The switcher and room-strip pill only ever list rooms the member has actually joined (rooms.filter(isMember)); the full non-archived room list backs the new browse sheet below instead.
Browse/join/leave via a new ManageRoomsSheet.vue, not SubCommunityBrowser (B1). SubCommunitySwitcher's "+ Browse all rooms" now opens ManageRoomsSheet.vue (ionic_frontend/src/components/teams/) — a new sheet, not the existing SubCommunityBrowser.vue primitive, which is onboarding-locked (batch self-select, no leave affordance) and out of bounds to modify per Orion's task package. The sheet lists "Your rooms" (each with a soft-confirm Leave, except the un-leaveable default room) and "Discover" (Join, immediate joinSubCommunity call), backed by a new useSubCommunityMembership.ts composable (mirrors useCreateCollectiveGoal.ts's mutation/toast/refetch shape) that tracks per-room pending state so one row's spinner doesn't disable the rest.
Gated ("request"-policy) rooms lock to "Request sent" — no approval workflow exists (documented deviation, not a bug). The UI-SPEC assumed joinSubCommunity would treat a request-policy room as a join request; the backend interaction actually hard-refuses non-open-policy rooms outright (see GraphQL Reference § joinSubCommunity) — no request/approval table exists anywhere in the schema. ManageRoomsSheet's "Ask to join" mirrors the already-shipped SubCommunityBrowser.vue's own client-side-only "Request sent" lock: it adds the room to a local Set and shows a toast, without calling the mutation or creating any membership row. This is a faithful, non-deceptive mirror of an existing shipped pattern, confirmed in review (Roy, 2026-07-28) — not a regression, and not something to "fix" by wiring the mutation, since there is nowhere server-side for an approval request to go yet. A real approval workflow is a future feature, not scoped here.
Post-accept multi-room landing now renders real data. acceptTeamInvite returns team (see GraphQL Reference § acceptTeamInvite); JoinTeamView uses it to call setActiveTeamId before routing to /team-home?browse=1&preselected=… for a multi-community joiner (/team-home takes no teamId route param — single-team beta). TeamHomeView renders SubCommunityBrowser itself when it sees ?browse=1, composed against its own TEAM_HOME_QUERY read (team.coachName/memberCount + rooms) rather than a dedicated query — Desi's UI-SPEC was explicitly agnostic on which query backs this (A2). A single-room joiner skips the picker entirely, as before.
Read-only (grace period) gates joining and leaving, not switching. ManageRoomsSheet disables Join/Leave and shows a banner when the team is in grace_readonly billing state (client-side check backed by the existing teamReadOnly mutation-response field as a server-side backstop); switching your active room is a local view filter, not a mutation, so it stays available even read-only.
Known non-blocking follow-up: teamHome.rooms[].isMember now runs one members.exists? query per room with no preload — tracked as OBJ-1851, bounded by Team::MAX_SUB_COMMUNITIES = 10. See GraphQL Reference § teamHome — known issue.
Room edit wired (OBJ-1824, Teams V1.1 backlog)
v4.19 Phase 4 honestly gated room "Edit" behind a disabled Edit room · soon action-sheet row, since a room's name, description, icon, join policy, and lead were write-once at creation — the only correction available to an admin was archive-and-recreate, which loses the room's membership. This gap is now closed: an owner/admin can open ⋮ → Edit room on a non-archived room and edit all five fields in EditSubCommunityModal.vue, which mirrors CreateSubCommunityModal.vue's structure and read-only/focus-trap handling. Backed by a new updateSubCommunity mutation (Teams::UpdateSubCommunity interaction, mirrors Teams::CreateSubCommunity's guard chain) — see GraphQL Reference § updateSubCommunity. No migration was required; every edited column already existed on communities.
Edit is admin-only and archived rooms are not editable, matching the not_archived? guard ArchiveSubCommunity/SetDefaultSubCommunity already enforce during the 30-day read-only archive window.
Lead can be reassigned, never cleared. There is no lead column — the lead is the admin-role CommunityMember. Reassigning demotes the current admin-role member to 'member' and promotes/creates the new lead as 'admin', in one transaction, so a room always has exactly one admin. A CommunityType.leadMembershipId field (batched per-team, mirroring the existing leadName batching) was added so the modal's Lead select can prefill against a stable key instead of the display-only first name — see GraphQL Reference § Community.leadMembershipId.
"Make default" stays out of the edit modal — SetDefaultSubCommunity already owns that control from the same ⋮ menu; the modal shows a read-only Default chip instead of a second write path to the same field.
Grace-period read-only is enforced server-side, not by hiding the menu item — matching the existing Edit room-adjacent Set as default/Archive rows, which are not client-gated on readOnly either. The mutation returns teamReadOnly: true, and the modal toasts and closes without a refetch.
Known non-blocking product wart, not a regression introduced here: switching a room's join policy open → request makes it unjoinable, since no approval workflow exists (joinSubCommunity hard-refuses non-open-policy rooms — see the "Gated ("request"-policy) rooms" note above). This was already true of room creation; edit is at parity, not a new gap.
Collective goals
Shipped (Phase 5, OBJ-1094, PR #1303). Args are unwrapped (no
input:wrapper). Also addsacknowledgeCollectiveGoalPrivacyContract(not sketched below at PRD time) and ateamHome.collectiveGoal/collectiveGoalPrivacyContractAcknowledgedread side. See GraphQL Reference § Collective Goals (Teams V1) for the full arguments, return shape, and behavior notes.
mutation createCollectiveGoal(teamId: String!, communityId: String, name: String!, description: String, targetValue: Int!, targetMetric: String!): CreateCollectiveGoalPayload!
mutation optIntoCollectiveGoal(collectiveGoalId: String!, personalGoalId: String!): OptIntoCollectiveGoalPayload!
mutation optOutOfCollectiveGoal(collectiveGoalId: String!): OptOutOfCollectiveGoalPayload!
mutation acknowledgeCollectiveGoalPrivacyContract: AcknowledgeCollectiveGoalPrivacyContractPayload!
type CollectiveGoalType { publicId, title, createdBy, scope, current, target, daysLeft, contributors, yourContribution }In-app collective-goal creation wired (v4.19, OBJ-1829)
createCollectiveGoal shipped in Phase 5 (OBJ-1094) with zero frontend callers — a dark feature: the mutation and its CREATE_COLLECTIVE_GOAL_MUTATION gql constant worked end-to-end, but nothing in the app could invoke them. v4.19 Phase 2 builds the missing owner/admin creation surface; no new mutation, interaction, or feature flag was needed.
Entry points. Primary: TeamHomeView.vue's empty-state card, replacing the dead "No team goal yet" text with a "Set a team goal" CTA for owners/admins (members see "keep logging, and it'll show up here when your coach sets one"). Secondary: a compact "Team goal / New goal" row on Team Settings → Sub-Communities tab (TeamSubCommunitiesTab.vue). Both entry points open the new CreateCollectiveGoalModal.vue, backed by the new useCreateCollectiveGoal.ts composable — reusing CreateSubCommunityModal.vue's chrome, focus-trap, and read-only handling verbatim rather than inventing new modal patterns.
Metric set: all three non-custom metrics are live-selectable (OBJ-1834). v4.19 Phase 2 shipped the "What counts" <select> with only check_ins selectable — UAT (Vicki) traced the aggregation path end-to-end and found milestones_completed/members_active_days accepted the value but never incremented events_contributed, so a goal created on either would render permanently stuck at 0 / target. Building the missing aggregation was out-of-scope for that "wiring completion" phase; per the milestone's own charter (ROADMAP line 28: "anything deliberately deferred to V1.1 should render as an honest 'coming soon' placeholder, never an enabled dead button"), the two options shipped as disabled <option>s with a "— coming soon" suffix rather than live dead-ends, and useCreateCollectiveGoal.ts hardcoded targetMetric: 'check_ins' as a defense-in-depth guard.
OBJ-1834 built the real aggregation and removed both guards. What each metric counts:
check_ins— every check-in or goal update a member logs on the personal goal they linked here. Unchanged from Phase 5 (OBJ-1094).milestones_completed— every milestone (sub-goal) a member finishes on the goal they linked here, credited to that goal's contribution the momentGoalTracking::UpdateGoalmarks it complete. Un-checking and re-checking the same milestone credits once, not once per toggle.members_active_days— any day a member logs a check-in or goal progress update, on any of their goals, credited once per calendar day in the member's own timezone. Mood logs don't count toward this — they don't fire a Teams job today, and adding that call site was ruled out of scope for this ticket.
custom remains fully excluded from the select — there's no custom-label column and CollectiveProgressCard would render a raw, unitless number, so it's not offered regardless of aggregation status.
No backfill. A collective goal created on a gated metric before OBJ-1834 shipped (reachable via the API even while the UI gated it) starts counting from ship-forward, not retroactively — existing goals on milestones_completed/members_active_days stay at 0 for any activity that happened before the un-gate.
Read-only handling. A billing-grace-period team gets LockedWriteButton before any request is sent (reusing the same bannerState.value === 'grace_readonly' check TeamSettingsView already computes); the mutation's teamReadOnly: true response is the server-side backstop if that client-side check is ever stale.
Native boundary (OBJ-1811). Goal creation is content, not a purchase — unlike the Stripe-checkout-backed billing CTAs above, this CTA is available on native with no isNative gate.
Known gap: no edit/delete surface exists for a collective goal yet. The Settings "Team goal" row stays visible even once a goal is set, since there's nothing else for it to do — flagged for a future phase.
Leaderboards
Shipped (Phase 4, OBJ-1093, PR #1280). Args are unwrapped (no
input:wrapper),teamId/communityIdareString(notID), and the query returns a wrappingTeamLeaderboardobject rather than a bare list. See GraphQL Reference § Leaderboards (Teams V1) for the full arguments, return shape, and admin anti-metric query.
query teamLeaderboard(teamId: String!, communityId: String, period: String!): TeamLeaderboard
mutation setLeaderboardVisibility(teamId: String!, optedOut: Boolean!): SetLeaderboardVisibilityPayload!
query teamLeaderboardOptOutStats: [TeamLeaderboardOptOutStat!] # admin-only, LEADERBOARD-5 anti-metric
type TeamLeaderboard { entries: [TeamLeaderboardEntry!]!, period: String!, rooms: [Community!]!, team: Team!, yourMembershipOptedOut: Boolean! }
type TeamLeaderboardEntry { isYou: Boolean!, points: Int!, rank: Int!, trend: String!, user: User! }Sub-community scope wired (OBJ-1826, Teams V1.1 backlog)
v4.19 Phase 4 honestly gated the leaderboard's scope control down to a static "Team-wide" <span> (Treatment T2 in the table above), since nothing in the frontend could switch it. This gap is now closed. The backend capability had existed since v4.0 — Query.teamLeaderboard already accepted communityId and Teams::TeamLeaderboardUpsertJob already wrote sub_community buckets per room — the only missing piece was a room list to populate a picker with and the frontend wiring to drive it.
TeamLeaderboardType gained a rooms field (rails_api/app/graphql/types/team_leaderboard_type.rb), populated by team_queries.rb#team_leaderboard with the same team.communities.where(archived_at: nil).order(is_default_for_team: :desc, name: :asc) expression teamHome.rooms already uses — every non-archived team room, default-first then A→Z. See GraphQL Reference § TeamLeaderboard.
The picker is joined-rooms-only, by UI-side filtering, not a resolver restriction. TeamLeaderboardView.vue filters the rooms list to isMember: true before it reaches TeamLeaderboard.vue's native <select>. leaderboard_community (the resolver backing the communityId argument) still accepts any non-archived team room — that permissiveness is deliberate and unchanged, recorded in the UI-SPEC as a documented, non-blocking design choice, not a privacy gap. The picker renders at ≥1 joined room; at 0 it falls back to today's static "Team-wide" pill.
Scope changes are board-region-isolated. Selecting a room refetches only the ranked-list/podium/empty-state region — never the whole-page skeleton the initial load uses — mirroring the same fix already shipped for the period toggle (useQuery's reactive variables getter refetches on its own; no explicit refetch() call). A dedicated region-error state keeps the picker itself usable so a member can pick a different scope after a failed refetch.
Room-scoped empty state. A room with no scored activity yet renders the existing zero-entry empty state with room-scoped copy instead of the team-wide wording — correct behavior, not a bug, since a room's sub_community bucket only fills once a member scores while a member of that room.
See docs/ui-specs/obj-1826-leaderboard-subcommunity-scope.md for the full design contract (control choice, option ordering, board-region states, a11y).
Team pulse survey
Shipped (Phase 7 GA, OBJ-1209/1210/1211). Net-new — Phase 6 never actually shipped this despite the original Phase 7 task package assuming a carry-forward. Args are unwrapped (no
input:wrapper). See GraphQL Reference § Team Pulse Survey (Teams V1) for full arguments, return shape, and behavior notes, and §5 Data-boundary contract above for the "no team-admin read path" rule this surface exists to honor.
mutation submitTeamPulse(teamId: String!, score: Int!, comment: String): SubmitTeamPulsePayload!
query teamPulseEligibility(teamId: String!): Boolean # null when not a member (PRIVACY-2)
type TeamPulseConfirmationType { period: String! } # score/comment deliberately never echoed backTeam types
type TeamType {
id, name, slug,
subscription: TeamSubscriptionType,
membership: TeamMembershipType, # current user's membership
communities: [CommunityType!]!
}
# CommunityType gains:
# team: TeamType (nullable — null for free/public communities)
# isDefaultForTeam: Boolean!
coachName/memberCountadded (v4.19, OBJ-1830). BackSubCommunityBrowser's post-accept team-summary header. See GraphQL Reference § myTeam.
myTeams wired (OBJ-1821, Teams V1.1 backlog)
myTeam (Resolvers::TeamQueries#my_team) has always resolved via TeamMembership.find_by(user_id: ...) — an arbitrary membership with no ordering or tiebreak, silently wrong for any user on more than one team. The new myTeams query is the first team-listing query to return every membership the viewer holds, ordered joined_at ASC. myTeam itself is untouched and still consumed as-is by useTeam.ts/TeamSettingsView.vue. See GraphQL Reference § myTeams for the full field list, PRIVACY-2 scoping, and example query.
query myTeams: [TeamSwitcherEntry!]! # [], never null, for a user with no memberships
type TeamSwitcherEntry { role: String!, team: TeamType! }Backs TeamSwitcher.vue, a new bottom sheet mirroring SubCommunitySwitcher.vue's modal contract, opened from a header control in TeamHomeView.vue that renders only when myTeams.length > 1. Exactly one team still renders the plain <span> label unchanged from the v4.19 Phase 4 de-affordance — see §8 Wiring audit follow-up § Honest-gating pass below. Active-team selection persists to localStorage (objectuve_active_team_id), a client-side view preference rather than a server-side field.
7. Feature flags
All five flags must be registered in both PostHog project 368400 AND ionic_frontend/src/lib/featureFlags.ts, then synced via:
node scripts/sync-posthog-feature-flags.mjs --applyCI enforces parity via the posthog-flag-drift job in .github/workflows/ci.yml. See docs/development/feature-flags.md for the full flag lifecycle (introduce → rollout → GA → retire).
| Flag | Type | Scope |
|---|---|---|
teams_enabled | boolean | Top-level Teams feature visibility; open during beta, retired at GA |
teams_billing_v1 | boolean | Kill switch for Stripe webhook handling (checkout.session.completed + subscription events) |
teams_collective_goals | boolean | Kill switch for §7.3 collective goals feature |
teams_leaderboards | boolean | Kill switch for §7.4 leaderboards and points engine |
teams_notifications | boolean | V1.1 backlog (OBJ-1819/OBJ-2406) — kill switch for the six team_* notification kinds and the notification bell/settings row. Independent of teams_leaderboards, which still separately gates leaderboard_recap. See §8 Notification centre shipped |
teams_bulk_invite | boolean | V1.1 only — CSV bulk invite flow |
All flags retire within one release cycle of GA per the flag-lifecycle policy.
8. Surface → component inventory
ionic_frontend/src/components/teams/ unless noted. Internal-staff M11 lives in admin_dashboard/.
| Wireframe | Surface | App | Scope | Component / file | Status |
|---|---|---|---|---|---|
| N12 | Sub-Community Browser (self-select) | ionic_frontend | V1 | SubCommunityBrowser.vue | ✅ Delivered |
| N12 | Sub-Community Browser — Room card | ionic_frontend | V1 | RoomCard.vue | ✅ Delivered |
| N12 | Sub-Community Browser — Pinned room | ionic_frontend | V1 | PinnedRoom.vue | ✅ Delivered |
| N12 | Sub-Community Browser — Privacy strip | ionic_frontend | V1 | PrivacyStrip.vue | ✅ Delivered |
| N12 | Sub-Community Browser — Footer actions | ionic_frontend | V1 | FooterActions.vue | ✅ Delivered |
| N19 | Team Leaderboard view | ionic_frontend | V1 | TeamLeaderboard.vue | ✅ Delivered |
| N19 | Leaderboard — row component | ionic_frontend | V1 | TeamLeaderboardRow.vue | ✅ Delivered |
| N19 | Leaderboard — period toggle | ionic_frontend | V1 | LeaderboardPeriodToggle.vue | ✅ Delivered |
| N19 | Leaderboard — wired route /team-leaderboard | ionic_frontend | V1 | views/teams/TeamLeaderboardView.vue | ✅ Delivered |
| N15 | Collective Progress card (embedded) | ionic_frontend | V1 | CollectiveProgressCard.vue | ✅ Delivered |
| N16 | Collective Goal detail view | ionic_frontend | V1 | CollectiveGoalDetail.vue | ✅ Delivered |
| N16 | Collective Goal detail — contribution bar | ionic_frontend | V1 | ContributionBar.vue | ✅ Delivered |
| — (OBJ-1829, net-new, no PRD wireframe) | Collective Goal creation modal | ionic_frontend | V1 | CreateCollectiveGoalModal.vue | ✅ Delivered |
| — (OBJ-1829, net-new, no PRD wireframe) | Collective Goal creation — flow composable | ionic_frontend | V1 | useCreateCollectiveGoal.ts | ✅ Delivered |
| N14 (UI-SPEC decision B1) | Browse/join/leave sub-communities sheet | ionic_frontend | V1 | ManageRoomsSheet.vue | ✅ Delivered |
| — (OBJ-1830, net-new, no PRD wireframe) | Join/leave — flow composable | ionic_frontend | V1 | useSubCommunityMembership.ts | ✅ Delivered |
| — (OBJ-1824, net-new, no PRD wireframe) | Edit sub-community (room) modal | ionic_frontend | V1.1 | EditSubCommunityModal.vue | ✅ Delivered |
| N7b | Members tab — member row | ionic_frontend | V1 | TeamMemberRow.vue | ✅ Delivered |
| N7b | Members tab — role badge | ionic_frontend | V1 | TeamRoleBadge.vue | ✅ Delivered |
| N22 | Trial/past-due/grace banner | ionic_frontend | V1 | TrialStatusBanner.vue | ✅ Delivered |
| N1 | Pricing page — Teams tier | marketing_landing | V1 | marketing_landing/index.html (Teams panel, id="teams") | ✅ Delivered |
| N2 | In-app Teams upgrade banner | ionic_frontend | V1 | TeamsUpgradeBanner.vue | 📋 Spec-only |
| N3 | Team creation / plan selector | ionic_frontend | V1 | views/teams/CreateTeamView.vue | ✅ Delivered |
| N4 | Clerk Billing checkout | external (Stripe) | V1 | — | 📋 Spec-only |
| N5 | Team provisioning success → invite empty state | ionic_frontend | V1 | TeamSettingsView.vue (Invites tab) | 📋 Spec-only |
| N6 | Convert existing community → Team | ionic_frontend | V1 | UpgradeCommunityToTeamModal.vue | 📋 Spec-only |
| N7 | Team Settings shell (tabbed) | ionic_frontend | V1 | TeamSettingsView.vue | ✅ Delivered |
| N7a | Team Settings · Billing tab | ionic_frontend | V1 | TeamBillingTab.vue | ✅ Delivered |
| N7c | Team Settings · Sub-Communities tab | ionic_frontend | V1 | TeamSubCommunitiesTab.vue | 📋 Spec-only |
| N7d | Team Settings · Invites tab | ionic_frontend | V1 | TeamInvitesTab.vue | 📋 Spec-only |
| N7e | Team Settings · Privacy panel preview | ionic_frontend | V1 | TeamPrivacyPreviewTab.vue | 📋 Spec-only |
| N8 | Invite-member modal | ionic_frontend | V1 | InviteMemberModal.vue | 📋 Spec-only |
| N9 | Create sub-community modal | ionic_frontend | V1 | CreateSubCommunityModal.vue | 📋 Spec-only |
| N10 | Archive sub-community confirm | ionic_frontend | V1 | ArchiveSubCommunityModal.vue | 📋 Spec-only |
| N11 | Public join landing /join-team/{code} | ionic_frontend | V1 | JoinTeamView.vue | 📋 Spec-only |
| N13 | Team Home — dashboard | ionic_frontend | V1 | TeamHomeView.vue | ✅ Delivered |
| N14 | Sub-Community switcher (embedded nav) | ionic_frontend | V1 | SubCommunitySwitcher.vue | 📋 Spec-only |
| N17 | Collective Goal opt-in modal | ionic_frontend | V1 | CollectiveGoalOptInModal.vue | ✅ Delivered |
| N18 | Privacy contract panel | ionic_frontend | V1 | TeamPrivacyContractView.vue | ✅ Delivered |
| N20 | Leaderboard opt-out toggle (embedded) | ionic_frontend | V1 | — (in member settings) | 📋 Spec-only |
| N21 | Monday Recap in-app card | ionic_frontend | V1 | MondayRecapCard.vue | ✅ Delivered |
| N21 | Monday Recap — bottom-sheet modal | ionic_frontend | V1 | MondayRecapModal.vue | ✅ Delivered |
| N21 | Monday Recap — modal-state composable | ionic_frontend | V1 | useRecapModal.ts | ✅ Delivered |
| N23 | Team email — invite | rails_api | V1 | TeamInviteMailer#team_invite_email | 📋 Spec-only |
| N24 | Team email — trial ending / payment failed | rails_api | V1 | TrialEndingSoonJob, billing mailers | 📋 Spec-only |
| N25 | Team email — welcome / upgraded / downgraded | rails_api | V1 | TeamUpgradedJob, TeamDowngradedJob | 📋 Spec-only |
| N26 | Seat-cap blocker modal | ionic_frontend | V1 | SeatCapBlockerModal.vue | 📋 Spec-only |
| N27 | Bulk CSV invite | ionic_frontend | V1.1 | — (N7d extension) | 📋 Spec-only |
| N28 | Team Admin Dashboard (analytics) | ionic_frontend | V1.1 | TeamAdminDashboardView.vue | 📋 Spec-only |
| N29 | Custom Badges admin | ionic_frontend | V1.1 | — (inside sub-community settings) | 📋 Spec-only |
| N30 | Manager View (grid) | ionic_frontend | V1.1 | TeamManagerView.vue | 📋 Spec-only |
| N31 | Accountability pair card (embedded) | ionic_frontend | V1.1 | — | 📋 Spec-only |
| N32 | Sub-community-scoped invite link | ionic_frontend | V1.1 | — | 📋 Spec-only |
| N33 | Per-team brand customization | ionic_frontend | V1.2 | — (N7 subtab) | 📋 Spec-only |
| N34 | Slack / MS Teams integration config | ionic_frontend | V1.2 | — (N7 subtab) | 📋 Spec-only |
| N35 | External Coach role management | ionic_frontend | V1.2 | — (N7b extension) | 📋 Spec-only |
| M1 | Community feed (sub-community variant) | ionic_frontend | V1 | CommunityView.vue (delta) | 📋 Spec-only |
| M2 | Community Settings — Upgrade to Teams | ionic_frontend | V1 | CommunitySettings (delta) | 📋 Spec-only |
| M3 | Marketing landing — Teams pricing | marketing_landing | V1 | marketing_landing/index.html (delta) | ✅ Delivered |
| — | Marketing landing — Teams showcase (Phase 8, net-new) | marketing_landing | V1 | marketing_landing/index.html (id="teams-showcase") | 🔄 In review — PR #1378 |
| M4 | App side-nav / root tab bar | ionic_frontend | V1 | nav delta | 📋 Spec-only |
| M5 | User settings → Privacy | ionic_frontend | V1 | settings delta | 📋 Spec-only |
| M6 | User profile (own) | ionic_frontend | V1 | profile delta | 📋 Spec-only |
| M7 | Personal Goal detail — collective-goal contribution pill | ionic_frontend | V1 | Goal.vue (delta) | ✅ Delivered |
| M8 | Personal Goal create flow | ionic_frontend | V1 | goal create delta | 📋 Spec-only |
| M9 | Notifications inbox | ionic_frontend | V1 | inbox delta | 📋 Spec-only |
| M10 | Onboarding (team-invite arrival) | ionic_frontend | V1 | onboarding branch | 📋 Spec-only |
| M11 | Admin Dashboard — internal Teams view | admin_dashboard | V1 | TeamsMonitoringView.vue (/teams/monitoring) | ✅ Delivered |
| M12 | Mailer base layout | rails_api | V1.2 | mailer delta | 📋 Spec-only |
| M13 | GraphQL Apollo cache shapes | ionic_frontend | V1 | CommunityType gains team field | 📋 Spec-only |
| M14 | Feature flag registry | ionic_frontend | V1 | src/lib/featureFlags.ts + PostHog | 📋 Spec-only |
| M15 | Home dashboard — "Your teams" strip | ionic_frontend | V1 | Dashboard.vue (delta) | 📋 Spec-only |
| M16 | Goals list — team-contribution pill | ionic_frontend | V1 | goals list delta | 📋 Spec-only |
| M17 | Achievement Hall — team filter | ionic_frontend | V1.1 | Hall delta | 📋 Spec-only |
| M19 | Profile — Teams strip + admin member view | ionic_frontend | V1 | profile delta | 📋 Spec-only |
| M20 | Settings tree — "Your teams" branch | ionic_frontend | V1 | settings delta | 📋 Spec-only |
| M21 | Notifications inbox — multi-source filters | ionic_frontend | V1 | inbox delta | 📋 Spec-only |
| M22 | Communities tab — "Your teams" pinned section | ionic_frontend | V1 | Communities tab delta | 📋 Spec-only |
| M23 | Personal onboarding card for Path B users | ionic_frontend | V1 | low-weight new card | 📋 Spec-only |
| M24 | Leaving-a-team archive of N18 in Data & export | ionic_frontend | V1.2 | data-export delta | 📋 Spec-only |
| §3 (UI-SPEC) | Team Pulse Survey (quarterly NPS anti-metric) | ionic_frontend | V1 | TeamPulseSurvey.vue | ✅ Delivered |
| §3 (UI-SPEC) | Team Pulse Survey — entry conditions + submit controller | ionic_frontend | V1 | useTeamPulse.ts | ✅ Delivered |
N7 note: the tabbed shell and its Billing subtab (N7a) are live at /teams/settings; the top-level teams_enabled gate was retired at GA, so these are no longer gated by it — access requires authentication only (kill switches, where they apply, are unaffected by this retirement). The Members/Communities/Invites/Privacy subtabs (N7b–N7e) render as disabled "Soon" placeholders — only their standalone presentational components (e.g. TeamMemberRow.vue) have shipped so far, not the tab content itself.
Source-of-truth gap: Desi's UI-SPEC for this billing-UI work (desi/obj-1103-teams-billing-ui-spec) was never pushed to origin — confirmed absent from both local and remote branches during this update. §8 rows above were verified directly against the shipped code and Desi's phase-7-teams-surfaces-and-wireframes.md wireframe entries (N3, N7, N7a, N22) instead. Ask Desi to push the branch retroactively so a real design-contract source exists for this work.
Honest-gating pass (v4.19 Phase 4, OBJ-1831)
Closes the milestone (final phase) and the last row of the OBJ-1817 audit: eight V1.1-shaped stubs that shipped to GA rendering as enabled dead controls (tap did nothing, or in one case quietly discarded a real emit) now honestly gate to their V1.1 backlog ticket, and the fresh-member "Mark today done" CTA got a real destination instead of a console.info no-op. No enabled Teams control silently no-ops anymore. Shipped PR #1848, branch phase-4-fresh-member-cta-honest-gating → gsd/v4.19-teams-v1-wiring, commit 02266d0a7.
Two gating treatments, not one generic "coming soon" tag (Desi's UI-SPEC, .planning/phases/v4.19-phase-4-fresh-member-cta-honest-gating/UI-SPEC.md):
- T1 — "Soon" affordance: the control stays visible, muted,
aria-disabled(or nativedisabledfor<select>/action-sheet items), and carries the new sharedComingSoonPill.vue. Where it previously fired a handler, it now surfaces a calmtoast.info(...)instead of routing or mutating. - T2 — static-label de-affordance: the control was never honestly a control — a
▾chevron implying a picker with no destination. The chrome is removed outright rather than gated; a plain label remains.
| Stub | Ticket | Treatment | Component |
|---|---|---|---|
| OBJ-1819 | T1 → ✅ shipped (Stage 1–3) | TeamHomeView.vue | |
| "How points work" | OBJ-1820 | T1 | TeamLeaderboard.vue (embedded in TeamLeaderboardView.vue) |
| OBJ-1821 | T2 → ✅ shipped (Teams V1.1 backlog) | TeamHomeView.vue | |
| OBJ-1825 | T1 → ✅ shipped v4.35 Phase 1 | TeamSubCommunitiesTab.vue |
Every former console.info/discarded-emit handler is fully removed, not just visually hidden: onNotificationsClick/onHowPoints now fire real toast.info() calls instead of logging; TeamLeaderboard's change-scope emit and its TeamLeaderboardView handler are deleted outright (the spec's stated preference over a disabled no-op stub); the room-menu openFeed/"Edit" handlers and TeamMembersTab's subCommunityFilter ref are deleted along with their now-honest dead controls. Each gated control carries a // Gated for V1.1 — OBJ-18xx code comment naming its ticket. The archived-room banner copy changed from "readable until {date}" to "kept until {date} — you'll be able to reopen them here soon," since Open Feed being gated means there's nothing to read in the meantime.
Fresh-member CTA (HOME-1) — wired, not gated. No team-scoped check-in mutation exists in the GraphQL schema (only a checkInsPerWeek stat field), so rather than invent one, TeamHomeView.onMarkTodayDone now routes to the app's existing personal check-in surface (router.push('/dashboard')); the button label changed from "Mark today done" to "Log today's check-in." This is Outcome A of Desi's two-outcome spec (wire if a real destination exists, else an informational gated-fallback empty state) — Outcome B's fallback copy was specced but not needed.
Challenges tile / OBJ-1811 coordination. No enabled Challenges control exists in any shipped V1 surface — "challenges" appears only in prose. Nothing to gate, and no native-reachable CTA is introduced by this pass.
Verification: Vitest 7322/7322 passing (573 files, incl. regression tests asserting each former no-op handler is gone), vue-tsc --noEmit clean, CI 21/21 required checks green. Roy's review confirmed every microcopy string, token, and treatment matches Desi's UI-SPEC and mockup screenshots verbatim, with no design drift.
Room feed shipped (v4.35 Phase 1, OBJ-1825)
The Open feed gate this section documented above is closed. A team member who belongs to a room now taps ⋮ → Open feed and lands on that room's feed — read/write for an active room, read-only for an archived one (composer and reactions suppressed) for the remainder of its 30-day retention window. Edit room · soon (OBJ-1824) stays gated in the same menu; it ships in Phase 2.
No new route or view was built — the room feed reuses the existing /communities/:publicId route (ionic_frontend/src/views/Community.vue) with an additive team-room mode, gated on community.teamId presence. Public-community rendering (teamId null) is byte-identical to before. Full field/state documentation: Features → Communities § Team Sub-Community Fields. The membership/archived-room authorization rules this surface depends on are documented once, centrally, at Architecture → Community Room Authorization — read that doc before adding any new mutation that touches a CommunityPost/comment/reaction.
Shipped PR #2210, branch phase-1-room-feed → gsd/v4.35-teams-v11-sub-communities, commit fdc444a76.
OBJ-1823 update (Teams V1.1 backlog, PR #2208): "View team activity" is wired, not gated — the row above is removed from the table for that reason. The member ⋮ menu now opens TeamMemberActivitySheet.vue, a read-only sheet backed by the new teamMemberActivity query (see §6 GraphQL surface § Member activity sheet wired above). The table's "eight V1.1-shaped stubs" count in the paragraph above describes the original Phase 4 pass and is unchanged as a historical count.
OBJ-1822 update (Teams V1.1 backlog, PR #2201): the sub-community filter <select> on Team Settings → Members is wired, not gated — the row above is removed from the table for that reason. TeamMembersTab.vue now filters by room (All rooms / an active room name / No room, archived rooms excluded), backed by the new roomIds field on TeamMemberSummaryType.
OBJ-1824 update (Teams V1.1 backlog, PR #2207): room "Edit" is wired, not gated — the row above is removed from the table for that reason. See §6 GraphQL surface § Room edit wired above for the full write-up.
OBJ-1826 update (Teams V1.1 backlog, PR #2220): the leaderboard scope switcher is wired, not gated — the row above is removed from the table for that reason. See §6 GraphQL surface § Sub-community scope wired above for the full write-up.
OBJ-1821 update (Teams V1.1 backlog, PR #2234): the team-picker chevron is wired, not gated — the row above is struck through rather than removed, mirroring OBJ-1825's treatment, since both close a T1/T2 gate from this exact eight-stub table. A member of two or more teams now gets a real header control (aria-haspopup="dialog") that opens TeamSwitcher.vue, a bottom sheet mirroring SubCommunitySwitcher.vue; a member of exactly one team still sees the plain <span> label, which is the correct terminal state for that case, not a temporary gate. This also closes a latent correctness bug — see §6 GraphQL surface § myTeams wired above for the full write-up.
Notification centre shipped (Stage 1–3, OBJ-1819)
The Notifications bell gate this section documented above is closed. Shipped across three stages: kinds + push gate (PR #2229), bell wiring into TeamHomeView.vue (OBJ-2321, PR #2252), and the "Team notifications" preference toggle (OBJ-2406, PR #2285) — merged a30a443f7.
Final scope: seven kinds, not six. UserNotification::TEAM_NOTIFICATION_KINDS covers leaderboard_recap (28) plus the six team_* kinds (31–36) — the pre-existing weekly leaderboard recap was folded into the same preference, ruled by Josh on 2026-08-11. Of the seven, three also push (leaderboard_recap, team_seat_cap_reached, team_billing_past_due); the other four are in-app only. team_collective_goal_reached (34) only ever fires for check_ins-metric collective goals — a goal on any other target_metric never reaches and never notifies (same limitation as §6's collective-goals metric-gating note above). Full kind table, emitter list, and push split: GraphQL Reference § Notifications (Teams V1).
Two independent gates, both must pass for the six team_* kinds:
teams_notificationsfeature flag (§7) — admin kill switch, checked per-emitter at each of the sixteam_*call sites, and gates whether the bell (TeamHomeView.vue) and the "Team notifications" settings row (Settings.vue) render at all.team_notifications_enabledpreference — the member's own choice (TeamNotificationsSettingsSection.vue, backed byUserDetail#team_notifications_enabled). Checked centrally inUserNotification.generate, which returns beforecreatewhen the preference is off — no record is ever created for a silenced kind.
leaderboard_recap only goes through gate 2. It keeps its own pre-existing teams_leaderboards admin kill switch (Teams::SendWeeklyRecapJob), completely unrelated to teams_notifications. leaderboard_opted_out? (a per-membership setting) silences neither gate — it only blanks a member's own stats inside a recap they still receive.
Query side: notificationHistory gained an optional teamId argument and every response now carries unreadCount; a new teamNotificationPreferences query and setTeamNotificationPreferences mutation back the settings toggle. Full arguments and behavior: GraphQL Reference § Notifications (Teams V1) and § Team Notifications (Teams V1) under Mutations.
9. Design assets provenance
The design prototype's durable artifacts are committed at .planning/milestones/teams-v1-design-assets/ — 19 screenshot composites under screenshots/ and prototype-tokens.css, extracted from the original Teams (1).zip design prototype (a Vue-on-CDN JSX prototype with already-built SFCs).
Token re-mapping: prototype scoped-CSS → repo Tailwind utilities
| Prototype token | Tailwind utility | Notes |
|---|---|---|
--accent #f28529 | bg-accent / text-accent | Orange accent — matches brand |
--gold #fcc419 | bg-gold / text-gold | Reserved for achievement highlights only |
--coach #a855f7 | bg-coach / text-coach | Coach surfaces only |
--success #10b77f | bg-success / text-success | Positive states |
| Glass surface | .glass-card utility | bg-card/70 backdrop-blur-xl border-border/60 dark:bg-card/40 |
| Brand gradient | .bg-gradient-primary | #177bbb → #05cbfc |
| Selection ring | ring-accent | Focus / selection states |
| Border radii | rounded-xl / rounded-2xl | Standard card radii |
Hover states: always use -interactive tokens (e.g., bg-accent-interactive), never /90 opacity — per the SYS-5 audit rule.
Icons: rendered through <AppIcon> (backed by Lucide for chrome glyphs) — no direct lucide-vue-next imports or Ionicons in Teams components. See Iconography System.
10. Execution
ROADMAP
Milestone planning artifact: .planning/milestones/v4.0-teams-v1-ROADMAP.md
The milestone version is v4.0 (assigned by Josh, 2026-07-03, at Multica kickoff — project 25f05e13-ee23-49d3-af63-cff743856a12, source issue OBJ-1089).
Kickoff runbook
See the Multica epic kickoff runbook section in the ROADMAP.
Phase 8 — Marketing landing Teams showcase
Net-new phase, appended after Phase 7 in the ROADMAP (not part of the original 7-phase plan). Adds a dedicated #teams-showcase section to marketing_landing/index.html telling the Teams story — private workspaces/sub-communities, leaderboards, collective goals — with real product screenshots and a nav entry, routing into the Phase 7 #teams pricing panel above. See the ROADMAP § Phase 8 for the full requirements and status.
⚠️ "Merged but no prod tag" is not isolation — deploy timing is not a feature-flag gate (OBJ-1386, 2026-07-12). Phase 8 merged to master on 2026-07-10 with no production tag cut. The ROADMAP recorded that as safe on the assumption that "the marketing landing deploy is coupled to the production
v*train," i.e. the showcase would only reach the public site alongside the Phase 7 GA tag. That assumption was false, and nothing was actually checking it.production.yml:440runsfirebase deploy --only hosting:enkidu-app,hosting:enkidu-marketing,hosting:enkidu-adminon every production tag, GA or not — andmarketing_landing/has no feature-flag code at all (grep -rn "teams_enabled|isFeatureFlagEnabled" marketing_landing/→ zero hits); it is a static site with no build step, and./deploy-sites.sh marketingcan push it standalone with no tag whatsoever. The "gate" was a coincidence of release-train timing, not a control.What happened: Josh cut
v4.0.1on 2026-07-12 as a manual unblock tag (to seedPlanrows and stand up the Stripe price-wiring job for OBJ-1339/OBJ-1222) — not the Phase 7 GA tag. Because it was still a productionv*tag, it carried the Phase 8 marketing payload anyway: the#teams-showcasesection, the Teams nav link, the $7/$70 pricing panel, and two "Start a team" / "Start a 14-day free trial" CTAs went live onobjectuve.comwhileteams_enabledwas still globally 0%. The CTAs pointed atapp.objectuve.com/teams/new, whichionic_frontend/src/router/index.ts:390fails closed and redirects to/dashboard— so a visitor who clicked through landed on an unexplained dashboard with no team and no error. Separately, and independent of the flag, productionPlan#stripe_price_idwas stillnilfor both Teams rows at that point, so checkout could not have completed even with the flag on. No money moved and no Teams data could be created — the app/API surfaces remained correctly flag-gated throughout; only the marketing site's public visibility was exposed.The call: OBJ-1386 asked Josh directly whether early visibility was intended. He confirmed yes on 2026-07-12 — the Teams marketing surfaces being publicly visible ahead of GA is an accepted, explicit, named exception to the ROADMAP's delivery isolation policy, not a silent one. No suppression, no CTA rework, and no rollback were needed as a result; the marketing markup and pricing stay live. (A separate, unrelated defect — three of the showcase's product screenshots were hand-built mockups rather than real app captures — was found and fixed by Josh directly in PR #1490 the same day.)
The lesson: a "gate" that is actually a coincidence of deployment sequencing is not a gate. If a surface must stay private, it needs a real feature-flag or subsystem check in the code (the top-level
teams_enabledflag played this role pre-GA and has since been retired) — per § Delivery isolation policy in the ROADMAP — not an assumption about which tag ships it.production.ymldeployinghosting:enkidu-marketingon every prod tag, and./deploy-sites.sh marketingdeploying it standalone with no tag at all, both mean the marketing site has effectively no deploy gate. Treat it as always-reachable when deciding what's safe to merge into it.
Phase 9 — North Star data completeness (METRIC-4)
Net-new phase, appended after Phase 8 in the ROADMAP (not part of the original 7-phase plan). Closes a gap GA shipped with: the North Star table below had five metrics but no complete data source behind them. See the ROADMAP § Phase 9 for the full requirements and status.
Shipped (2026-07-15). Teams::SnapshotNorthStarMetricsJob (Crono, daily 03:45) now snapshots MRR, paying/trialing team counts, and average seats into a team_metrics_snapshot PostHog event — see the Teams billing runbook § North Star metrics snapshot job for what it emits and how to verify it ran. Combined with the trial_converted (OBJ-1411), team_canceled, and team_downgraded events already instrumented (PR #1511), every metric in the § Success metrics summary table below now traces to a named PostHog source — no un-sourced panel. The dashboard/Insight config that reads these sources is not part of this phase.
Success metrics summary
| Metric | Target | Source |
|---|---|---|
| Paying teams (12mo) | 10–30 | team_metrics_snapshot.paying_teams |
| MRR (12mo) | $2K–$8K | team_metrics_snapshot.mrr_cents |
| Avg team size | 6–10 seats | team_metrics_snapshot.avg_seats_purchased (billed seats — what PRD §10's per-seat MRR math uses); avg_seats_occupied is the usage companion |
| Monthly logo churn | <5% | Computed in PostHog: team_canceled events (numerator) ÷ team_metrics_snapshot.paying_teams (denominator) |
| Annual revenue retention | ≥95% | Computed in PostHog: team_metrics_snapshot.mrr_cents net of team_canceled/team_downgraded events |
| Trial → paid conversion | ≥40% within 14 days | trial_converted event (OBJ-1411) |
| Invite link → join | ≥80% within 7 days | Invites (§7.2) |
| Time to first goal event after join | <48 hours median | Invites + onboarding |
| % teams with ≥1 collective goal | ≥40% within 30 days of paid | Collective goals (§7.3) |
| Leaderboard view rate | ≥70% of active members weekly | Leaderboards (§7.4) |
| Leaderboard opt-out rate | ≤10% | Leaderboards (§7.4) |
Anti-metrics (keep LOW): team push-notification opt-outs <5%/quarter; zero "feel surveilled" NPS signals — now captured directly via the team pulse survey (TeamPulseSurvey.vue, Phase 7 GA); session length P50 must not exceed 12 min for team members.
10-sprint sequencing (PRD Appendix C)
| Sprint | Focus | Exit signal |
|---|---|---|
| 1 | Pricing decisions, data model migration, TeamSubscription + Plan rows | Migration on staging; Plans seeded |
| 2 | Billing flow (§7.1), Clerk Billing integration, webhook plumbing | Test-mode subscription completes end-to-end |
| 3 | Private team invites (§7.2), join flow, Members tab | Closed beta cohort can be invited |
| 4 | Team Leaderboards (§7.4), points engine, weekly recap | Leaderboard reads/writes pass load test |
| 5 | Collective goals (§7.3), contribution opt-in flow, progress UI | Closed beta cohort opts in |
| 6 | Closed beta cohort onboarded; feedback intake; bug fixes | All 5 teams active for 14 days |
| 7 | V1.1 prep: custom badges, team admin dashboard analytics | Open beta cohort onboarded |
| 8 | V1.1: accountability pair auto-matching, manager view | Manager view shipped |
| 9 | V1.1: priority support routing; rollout polish | Support queue live |
| 10 | GA: pricing page, lifecycle email, press, flag retirement | $7 price live; teams_enabled flag retired |
Note: the presentational Storybook components for Sprints 3–5 (N12, N7b, N19, N15, N16, N22) are already delivered under ionic_frontend/src/components/teams/. The presentational layer is ready before backend wiring begins.
Last updated: 2026-08-21 — § Member activity sheet wired: recorded OBJ-2335/OBJ-2312 as resolved (PR #2547) — TeamMemberActivityType now exposes numeric lastActiveDays, restoring the 3-tier tint, and the two backend fields' shared INACTIVE_AFTER constant is confirmed to have never actually disagreed; updated the inline TeamMemberActivity type sketch to include the new field.
Prior update: 2026-08-14 — § Collective goals' honest-gate note resolved: OBJ-1834 shipped real aggregation for milestones_completed/members_active_days and un-gated both options (PRs #2335/#2336), so all three non-custom metrics are now live-selectable with definitions, the mood-logs exclusion, and the no-backfill decision written down.
Prior update: 2026-08-11 — struck through the OBJ-1819 "Notifications bell" row in § Honest-gating pass as shipped (Stage 1–3, PR #2229/#2252/#2285), added the § Notification centre shipped writeup, and registered the previously-undocumented teams_notifications flag in §7.
Prior update: 2026-08-09 — struck through the OBJ-1821 "Team-picker chevron" row in § Honest-gating pass as shipped (Teams V1.1 backlog, PR #2234) and added the § myTeams wired writeup under § Team types.
Prior update: 2026-08-08 — flipped the OBJ-1825 "Open feed" row in § Honest-gating pass from gated to shipped (v4.35 Phase 1, PR #2210) and added the § Room feed shipped writeup.
Prior update: 2026-08-08 — reconciled § Honest-gating pass: removed the OBJ-1822 (Members-tab room filter, PR #2201), OBJ-1824 (room Edit, PR #2207), and OBJ-1826 (leaderboard scope picker, PR #2220) rows now that all three are live on master, following the pattern already used for OBJ-1823.
Prior update: 2026-07-15 — added § Phase 9 (North Star data completeness, METRIC-4, shipped) and a Source column to § Success metrics summary tracing each metric to its PostHog event or snapshot property.
Prior update: 2026-07-12 — added the OBJ-1386 postmortem to § Phase 8: the marketing showcase, pricing panel, and CTAs went publicly live via the v4.0.1 tag on 2026-07-12, ahead of Phase 7 GA, because the "marketing deploy rides the GA v* train" assumption was false — production.yml deploys the marketing site on every prod tag, and it has no feature-flag gate of any kind. Josh confirmed early visibility is an accepted, named exception; no code changed. See the ROADMAP's § Delivery isolation policy for the corresponding correction.
Prior update: 2026-07-11 — rewrote the "config existing ≠ config wired" gotcha in Billing & provisioning (§6) for the final OBJ-1339 shape: stripe:wire_prices (slug-scoped, cross-mode-guarded) is the sole wiring mechanism, both original migrations are permanently neutralized no-ops, production execution is a dedicated non-auto-executing Cloud Run job, and staging/production Stripe price secrets are split by name (-live suffix on production) rather than by GCP project scoping. Recorded the two-attempt history, including the 2026-07-11 merge-then-revert of PR #1448 and the "never tighten a shared lint ahead of the wiring it enforces" lesson that came out of it.
Previous update: 2026-07-10 — recorded Phase 8 (marketing landing Teams showcase, PR #1378) in §8 and §10; corrected N1/M3 from "📋 Spec-only" to "✅ Delivered" now that the Phase 7 GA pricing panel (PR #1328/#1332) has merged.