Skip to content

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

HorizonPaying teamsAvg seatsMRR
12 months post-GA (Apr 2027)10–306–10$2K–$8K
3 years (Apr 2029)200–8008–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

DocumentDescription
./phase-7-teams-prd.mdFull 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.mdSurface inventory: N1–N35 new surfaces, M1–M24 modified surfaces, ASCII wireframes, sprint→surface sequencing table, dual-context user model (§5)
./teams-native-platform-scope.mdNative (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

DecisionResolution
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 modelSeparate 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.

#QuestionRecommendationOwnerStatus
1Final Teams price$7/mo, $70/yr, 14-day trialJosh✅ Confirmed — teams_monthly/teams_annual Plan rows (price_cents: 700/7000), TeamSubscription::TRIAL_DAYS = 14
2Data modelSeparate Team aggregate, Team has_many :communitiesJosh✅ Confirmed — shipped Phase 1
3Bulk CSV invite — V1 or V1.1?V1.1Josh⏸ Deferred — teams_bulk_invite flag exists but only gates single email invites in V1; no CSV surface yet
4Mood-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
5Annual discount magnitude17% ($70 vs. $84)Josh✅ Confirmed — 7000 vs 12 × 700 = 8400 cents (16.7% ≈ 17%)
6Trial requires card?NoJosh✅ 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
7Max team size at launch200Josh✅ Confirmed — StartTeamCheckout/AdjustTeamSeats both enforce seat_count.between?(1, 200)
8Timezone handling for weekly leaderboardsTeam-level, default America/New_YorkJosh✅ Confirmed — teams.timezone column, default "America/New_York"
9Refund policy7-day, manual via Stripe dashboardJosh✅ Confirmed — Teams::CancelTeamSubscription::REFUND_WINDOW_DAYS = 7; no automated refund method exists. See the Teams billing runbook
10Beta participant compensationFree during beta + 50% discount locked 6 months post-GAJosh✅ Confirmed — shipped Phase 6, Teams::GrantBetaParticipantCompensation (DISCOUNT_PERCENT = 50, DISCOUNT_LOCK_MONTHS = 6)
11Default sub-community auto-join behaviorAuto-join default on accept; browser only when ≥1 non-default existsJosh✅ Confirmed — AcceptTeamInvite#auto_join_default_community
12Sub-community archive grace period30 days read-only then paranoia-deletedJosh✅ Confirmed — Teams::ArchiveSubCommunity::ARCHIVE_WINDOW = 30.days
13External Coach seat-billable?Free addon, max 1 per team in V1.2Josh⏸ Deferred — no External Coach role/billing code shipped yet, on track for V1.2
14Cap on sub-communities per team10 in V1Josh✅ Confirmed — Team::MAX_SUB_COMMUNITIES = 10
15Can members create sub-communities?Admins only in V1Josh✅ Confirmed — Teams::CreateSubCommunity is admin-only
16Sub-community discovery policyMix per sub-community: open (default) or request-to-join toggleJosh✅ Confirmed — join_policy validates to open/request, default open

4. Data model map

Full migration sketch in PRD §5.

New tables

TablePurpose
teamsAggregate 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_subscriptionsLinks Team ↔ Plan ↔ Stripe. Tracks status (trialing/active/past_due/grace/canceled), seat_count, trial_ends_at, current_period_end.
team_membershipsThe 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_entriesDenormalized leaderboard rows per (team, community, user, period). Period: weekly/monthly/all_time. Recomputed via Sidekiq on every points-earning event.
collective_goalsTeam- or sub-community-scoped shared goal. Separate from personal Goal to keep the personal surface clean.
collective_goal_contributionsOpt-in contribution record linking a User to a CollectiveGoal via their personal goal. Tracks events_contributed.
team_invitesInvite record — link or email-targeted. Carries code, email (nullable), team_role, preselected_community_ids, expires_at, max_uses, status.
team_pulse_responsesQuarterly 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

TableNew columns
communitiesteam_id (nullable FK — null for free/public communities), is_default_for_team (boolean, default false)
plansAdd rows: teams_monthly, teams_annual
payment_recordsteam_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

RoleCAN seeCANNOT see (ever)
Team Owner / Team AdminTeam-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 scopeMessage-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 LeadFull activity within their sub-community (posts, members, leaderboard, collective goals)Cross-community visibility outside their sub-community
Team MemberFull activity within sub-communities they joinedSub-communities they didn't join; other members' personal data
External CoachRead-only within explicitly granted sub-communitiesTeam-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, and submitTeamPulse'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 in TeamPulseSurvey.vue states 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 id fields shown in the type sketches below denote the public_id-backed GraphQL identifier per the repo's PublicRecord convention — never the raw integer primary key.

Billing & provisioning

graphql
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_ANNUAL were live in enkidu-api-staging's Secret Manager config for a period with no code path anywhere in the repo that read themgrep -rn STRIPE_PRICE_TEAMS returned zero app-code hits — so teams_monthly/teams_annual kept a nil stripe_price_id despite 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.

  1. rake stripe:wire_prices is the sole supported wiring mechanism. It is slug-scoped (stripe:wire_prices[teams_monthly, teams_annual] wires only the given Plan slugs — 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 its livemode compared against the active STRIPE_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 silent ENV.fetch(..., existing_value) fallback.
  2. Both original migrations — 20260708000000_wire_test_mode_stripe_prices.rb and 20260708130000_retry_wire_test_mode_stripe_prices.rb — are now permanently neutralized to explicit no-ops. They used to write all four Plan rows 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 any STRIPE_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, both up/down bodies 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 its STRIPE_PRICE_* secret is wired, ENV.fetch silently falls back to nil, 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.
  3. Production execution is a dedicated, non-auto-executing Cloud Run job. The Rails app never reads STRIPE_PRICE_* at request time — Teams::StartTeamCheckout reads Plan#stripe_price_id from the database — so these vars are a wiring-time input only, wired into exactly one place: the enkidu-stripe-wire-prices Cloud Run job (.github/workflows/production.yml). The job is created/updated on every production deploy but is never executed automatically (no gcloud run jobs execute step for it) — it must be run manually, and only after a deploy has seeded the teams_monthly/ teams_annual Plan rows (20260704000010_seed_team_plans.rb) — see Deployment § Stripe price wiring for the exact command and the --args comma-splitting gotcha.
  4. 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." — when stripe_price_id is still blank, instead of a generic "Payment error" surfacing three layers down inside StripeService.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.sh to catch a STRIPE_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 no environment: scoping (same root cause as P6). For STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET (P6), the fix gave staging a -staging-suffixed secret and left production on the original name. For STRIPE_PRICE_TEAMS_*, it's reversed: staging keeps the original stripe-price-teams-monthly / stripe-price-teams-annual names (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_TEAMS should 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-staging and enkidu-worker-staging referenced STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET via 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-correct clerk-secret-key-dev (staging) vs clerk-secret-key (production) split. Staging was live-charging-capable using production's sk_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_…) and stripe-webhook-secret-staging (whsec_…, paired with a genuine test-mode webhook endpoint registered against staging's URL) — with deploy/service.staging.yaml and deploy/worker.staging.yaml repointed to them. deploy/*.production.yaml are untouched; production keeps stripe-secret-key / stripe-webhook-secret.

The regression guard: scripts/check-deploy-secret-drift.mjs, run by .github/workflows/deploy-secret-drift.yml on every PR touching deploy/**, parses all four deploy/*.yaml manifests and fails CI if any STRIPE_* env var resolves to the same secretKeyRef.name on 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.name staging points at is actually distinct from production's, the way Clerk's -dev suffix already does.

Invites & membership

graphql
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.

graphql
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 optedOut

Field 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

graphql
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 modalSetDefaultSubCommunity 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 adds acknowledgeCollectiveGoalPrivacyContract (not sketched below at PRD time) and a teamHome.collectiveGoal/collectiveGoalPrivacyContractAcknowledged read side. See GraphQL Reference § Collective Goals (Teams V1) for the full arguments, return shape, and behavior notes.

graphql
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 moment GoalTracking::UpdateGoal marks 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/communityId are String (not ID), and the query returns a wrapping TeamLeaderboard object rather than a bare list. See GraphQL Reference § Leaderboards (Teams V1) for the full arguments, return shape, and admin anti-metric query.

graphql
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.

graphql
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 back

Team types

graphql
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 / memberCount added (v4.19, OBJ-1830). Back SubCommunityBrowser'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.

graphql
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:

bash
node scripts/sync-posthog-feature-flags.mjs --apply

CI 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).

FlagTypeScope
teams_enabledbooleanTop-level Teams feature visibility; open during beta, retired at GA
teams_billing_v1booleanKill switch for Stripe webhook handling (checkout.session.completed + subscription events)
teams_collective_goalsbooleanKill switch for §7.3 collective goals feature
teams_leaderboardsbooleanKill switch for §7.4 leaderboards and points engine
teams_notificationsbooleanV1.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_invitebooleanV1.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/.

WireframeSurfaceAppScopeComponent / fileStatus
N12Sub-Community Browser (self-select)ionic_frontendV1SubCommunityBrowser.vue✅ Delivered
N12Sub-Community Browser — Room cardionic_frontendV1RoomCard.vue✅ Delivered
N12Sub-Community Browser — Pinned roomionic_frontendV1PinnedRoom.vue✅ Delivered
N12Sub-Community Browser — Privacy stripionic_frontendV1PrivacyStrip.vue✅ Delivered
N12Sub-Community Browser — Footer actionsionic_frontendV1FooterActions.vue✅ Delivered
N19Team Leaderboard viewionic_frontendV1TeamLeaderboard.vue✅ Delivered
N19Leaderboard — row componentionic_frontendV1TeamLeaderboardRow.vue✅ Delivered
N19Leaderboard — period toggleionic_frontendV1LeaderboardPeriodToggle.vue✅ Delivered
N19Leaderboard — wired route /team-leaderboardionic_frontendV1views/teams/TeamLeaderboardView.vue✅ Delivered
N15Collective Progress card (embedded)ionic_frontendV1CollectiveProgressCard.vue✅ Delivered
N16Collective Goal detail viewionic_frontendV1CollectiveGoalDetail.vue✅ Delivered
N16Collective Goal detail — contribution barionic_frontendV1ContributionBar.vue✅ Delivered
— (OBJ-1829, net-new, no PRD wireframe)Collective Goal creation modalionic_frontendV1CreateCollectiveGoalModal.vue✅ Delivered
— (OBJ-1829, net-new, no PRD wireframe)Collective Goal creation — flow composableionic_frontendV1useCreateCollectiveGoal.ts✅ Delivered
N14 (UI-SPEC decision B1)Browse/join/leave sub-communities sheetionic_frontendV1ManageRoomsSheet.vue✅ Delivered
— (OBJ-1830, net-new, no PRD wireframe)Join/leave — flow composableionic_frontendV1useSubCommunityMembership.ts✅ Delivered
— (OBJ-1824, net-new, no PRD wireframe)Edit sub-community (room) modalionic_frontendV1.1EditSubCommunityModal.vue✅ Delivered
N7bMembers tab — member rowionic_frontendV1TeamMemberRow.vue✅ Delivered
N7bMembers tab — role badgeionic_frontendV1TeamRoleBadge.vue✅ Delivered
N22Trial/past-due/grace bannerionic_frontendV1TrialStatusBanner.vue✅ Delivered
N1Pricing page — Teams tiermarketing_landingV1marketing_landing/index.html (Teams panel, id="teams")✅ Delivered
N2In-app Teams upgrade bannerionic_frontendV1TeamsUpgradeBanner.vue📋 Spec-only
N3Team creation / plan selectorionic_frontendV1views/teams/CreateTeamView.vue✅ Delivered
N4Clerk Billing checkoutexternal (Stripe)V1📋 Spec-only
N5Team provisioning success → invite empty stateionic_frontendV1TeamSettingsView.vue (Invites tab)📋 Spec-only
N6Convert existing community → Teamionic_frontendV1UpgradeCommunityToTeamModal.vue📋 Spec-only
N7Team Settings shell (tabbed)ionic_frontendV1TeamSettingsView.vue✅ Delivered
N7aTeam Settings · Billing tabionic_frontendV1TeamBillingTab.vue✅ Delivered
N7cTeam Settings · Sub-Communities tabionic_frontendV1TeamSubCommunitiesTab.vue📋 Spec-only
N7dTeam Settings · Invites tabionic_frontendV1TeamInvitesTab.vue📋 Spec-only
N7eTeam Settings · Privacy panel previewionic_frontendV1TeamPrivacyPreviewTab.vue📋 Spec-only
N8Invite-member modalionic_frontendV1InviteMemberModal.vue📋 Spec-only
N9Create sub-community modalionic_frontendV1CreateSubCommunityModal.vue📋 Spec-only
N10Archive sub-community confirmionic_frontendV1ArchiveSubCommunityModal.vue📋 Spec-only
N11Public join landing /join-team/{code}ionic_frontendV1JoinTeamView.vue📋 Spec-only
N13Team Home — dashboardionic_frontendV1TeamHomeView.vue✅ Delivered
N14Sub-Community switcher (embedded nav)ionic_frontendV1SubCommunitySwitcher.vue📋 Spec-only
N17Collective Goal opt-in modalionic_frontendV1CollectiveGoalOptInModal.vue✅ Delivered
N18Privacy contract panelionic_frontendV1TeamPrivacyContractView.vue✅ Delivered
N20Leaderboard opt-out toggle (embedded)ionic_frontendV1— (in member settings)📋 Spec-only
N21Monday Recap in-app cardionic_frontendV1MondayRecapCard.vue✅ Delivered
N21Monday Recap — bottom-sheet modalionic_frontendV1MondayRecapModal.vue✅ Delivered
N21Monday Recap — modal-state composableionic_frontendV1useRecapModal.ts✅ Delivered
N23Team email — inviterails_apiV1TeamInviteMailer#team_invite_email📋 Spec-only
N24Team email — trial ending / payment failedrails_apiV1TrialEndingSoonJob, billing mailers📋 Spec-only
N25Team email — welcome / upgraded / downgradedrails_apiV1TeamUpgradedJob, TeamDowngradedJob📋 Spec-only
N26Seat-cap blocker modalionic_frontendV1SeatCapBlockerModal.vue📋 Spec-only
N27Bulk CSV inviteionic_frontendV1.1— (N7d extension)📋 Spec-only
N28Team Admin Dashboard (analytics)ionic_frontendV1.1TeamAdminDashboardView.vue📋 Spec-only
N29Custom Badges adminionic_frontendV1.1— (inside sub-community settings)📋 Spec-only
N30Manager View (grid)ionic_frontendV1.1TeamManagerView.vue📋 Spec-only
N31Accountability pair card (embedded)ionic_frontendV1.1📋 Spec-only
N32Sub-community-scoped invite linkionic_frontendV1.1📋 Spec-only
N33Per-team brand customizationionic_frontendV1.2— (N7 subtab)📋 Spec-only
N34Slack / MS Teams integration configionic_frontendV1.2— (N7 subtab)📋 Spec-only
N35External Coach role managementionic_frontendV1.2— (N7b extension)📋 Spec-only
M1Community feed (sub-community variant)ionic_frontendV1CommunityView.vue (delta)📋 Spec-only
M2Community Settings — Upgrade to Teamsionic_frontendV1CommunitySettings (delta)📋 Spec-only
M3Marketing landing — Teams pricingmarketing_landingV1marketing_landing/index.html (delta)✅ Delivered
Marketing landing — Teams showcase (Phase 8, net-new)marketing_landingV1marketing_landing/index.html (id="teams-showcase")🔄 In review — PR #1378
M4App side-nav / root tab barionic_frontendV1nav delta📋 Spec-only
M5User settings → Privacyionic_frontendV1settings delta📋 Spec-only
M6User profile (own)ionic_frontendV1profile delta📋 Spec-only
M7Personal Goal detail — collective-goal contribution pillionic_frontendV1Goal.vue (delta)✅ Delivered
M8Personal Goal create flowionic_frontendV1goal create delta📋 Spec-only
M9Notifications inboxionic_frontendV1inbox delta📋 Spec-only
M10Onboarding (team-invite arrival)ionic_frontendV1onboarding branch📋 Spec-only
M11Admin Dashboard — internal Teams viewadmin_dashboardV1TeamsMonitoringView.vue (/teams/monitoring)✅ Delivered
M12Mailer base layoutrails_apiV1.2mailer delta📋 Spec-only
M13GraphQL Apollo cache shapesionic_frontendV1CommunityType gains team field📋 Spec-only
M14Feature flag registryionic_frontendV1src/lib/featureFlags.ts + PostHog📋 Spec-only
M15Home dashboard — "Your teams" stripionic_frontendV1Dashboard.vue (delta)📋 Spec-only
M16Goals list — team-contribution pillionic_frontendV1goals list delta📋 Spec-only
M17Achievement Hall — team filterionic_frontendV1.1Hall delta📋 Spec-only
M19Profile — Teams strip + admin member viewionic_frontendV1profile delta📋 Spec-only
M20Settings tree — "Your teams" branchionic_frontendV1settings delta📋 Spec-only
M21Notifications inbox — multi-source filtersionic_frontendV1inbox delta📋 Spec-only
M22Communities tab — "Your teams" pinned sectionionic_frontendV1Communities tab delta📋 Spec-only
M23Personal onboarding card for Path B usersionic_frontendV1low-weight new card📋 Spec-only
M24Leaving-a-team archive of N18 in Data & exportionic_frontendV1.2data-export delta📋 Spec-only
§3 (UI-SPEC)Team Pulse Survey (quarterly NPS anti-metric)ionic_frontendV1TeamPulseSurvey.vue✅ Delivered
§3 (UI-SPEC)Team Pulse Survey — entry conditions + submit controllerionic_frontendV1useTeamPulse.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-gatinggsd/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 native disabled for <select>/action-sheet items), and carries the new shared ComingSoonPill.vue. Where it previously fired a handler, it now surfaces a calm toast.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.
StubTicketTreatmentComponent
Notifications bellOBJ-1819T1 → ✅ shipped (Stage 1–3)TeamHomeView.vue
"How points work"OBJ-1820T1TeamLeaderboard.vue (embedded in TeamLeaderboardView.vue)
Team-picker chevronOBJ-1821T2 → ✅ shipped (Teams V1.1 backlog)TeamHomeView.vue
Room "Open feed" (room ⋮ menu, both active and archived branches)OBJ-1825T1 → ✅ shipped v4.35 Phase 1TeamSubCommunitiesTab.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-feedgsd/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:

  1. teams_notifications feature flag (§7) — admin kill switch, checked per-emitter at each of the six team_* call sites, and gates whether the bell (TeamHomeView.vue) and the "Team notifications" settings row (Settings.vue) render at all.
  2. team_notifications_enabled preference — the member's own choice (TeamNotificationsSettingsSection.vue, backed by UserDetail#team_notifications_enabled). Checked centrally in UserNotification.generate, which returns before create when 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 tokenTailwind utilityNotes
--accent #f28529bg-accent / text-accentOrange accent — matches brand
--gold #fcc419bg-gold / text-goldReserved for achievement highlights only
--coach #a855f7bg-coach / text-coachCoach surfaces only
--success #10b77fbg-success / text-successPositive states
Glass surface.glass-card utilitybg-card/70 backdrop-blur-xl border-border/60 dark:bg-card/40
Brand gradient.bg-gradient-primary#177bbb → #05cbfc
Selection ringring-accentFocus / selection states
Border radiirounded-xl / rounded-2xlStandard 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:440 runs firebase deploy --only hosting:enkidu-app,hosting:enkidu-marketing,hosting:enkidu-admin on every production tag, GA or not — and marketing_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 marketing can 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.1 on 2026-07-12 as a manual unblock tag (to seed Plan rows and stand up the Stripe price-wiring job for OBJ-1339/OBJ-1222) — not the Phase 7 GA tag. Because it was still a production v* tag, it carried the Phase 8 marketing payload anyway: the #teams-showcase section, the Teams nav link, the $7/$70 pricing panel, and two "Start a team" / "Start a 14-day free trial" CTAs went live on objectuve.com while teams_enabled was still globally 0%. The CTAs pointed at app.objectuve.com/teams/new, which ionic_frontend/src/router/index.ts:390 fails 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, production Plan#stripe_price_id was still nil for 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_enabled flag 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.yml deploying hosting:enkidu-marketing on every prod tag, and ./deploy-sites.sh marketing deploying 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

MetricTargetSource
Paying teams (12mo)10–30team_metrics_snapshot.paying_teams
MRR (12mo)$2K–$8Kteam_metrics_snapshot.mrr_cents
Avg team size6–10 seatsteam_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 daystrial_converted event (OBJ-1411)
Invite link → join≥80% within 7 daysInvites (§7.2)
Time to first goal event after join<48 hours medianInvites + onboarding
% teams with ≥1 collective goal≥40% within 30 days of paidCollective goals (§7.3)
Leaderboard view rate≥70% of active members weeklyLeaderboards (§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)

SprintFocusExit signal
1Pricing decisions, data model migration, TeamSubscription + Plan rowsMigration on staging; Plans seeded
2Billing flow (§7.1), Clerk Billing integration, webhook plumbingTest-mode subscription completes end-to-end
3Private team invites (§7.2), join flow, Members tabClosed beta cohort can be invited
4Team Leaderboards (§7.4), points engine, weekly recapLeaderboard reads/writes pass load test
5Collective goals (§7.3), contribution opt-in flow, progress UIClosed beta cohort opts in
6Closed beta cohort onboarded; feedback intake; bug fixesAll 5 teams active for 14 days
7V1.1 prep: custom badges, team admin dashboard analyticsOpen beta cohort onboarded
8V1.1: accountability pair auto-matching, manager viewManager view shipped
9V1.1: priority support routing; rollout polishSupport queue live
10GA: 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.

Loading…