# PlantTogether / Room — Server-Side Master Feature Spec (reverse-engineered) > Source of truth: `forest-server` (Ruby on Rails 6.1, Sidekiq, RSpec). Reverse-engineered, code-verified. > Generated 2026-06-30 · **Sprint 49 · value team** · *server-side, reverse-engineered, code-verified*. > Every claim carries a `file:line` reference into `forest-server`. Seed facts were re-checked against current code; corrections are called out inline. > Companion iOS/kit doc (the client contract this server serves): [`2026-06-30-planttogether-reverse-engineered-spec.md`](./2026-06-30-planttogether-reverse-engineered-spec.md). > > **Spec execution status:** the RSpec suites cited as evidence (`spec/controllers/api/v1/rooms_controller_spec.rb`, `spec/models/room_spec.rb`, `spec/models/participant_spec.rb`) were **read and cross-referenced** against the implementation, but **could not be executed** in this environment — `bundle install` fails building the `pg` native gem locally. Every behavioral claim is traced to source; spec citations confirm the maintainers assert the same behavior. > > **Overstatement sweep (verify-by-negation + trace-to-sink):** all ~65 absolute/capability claims were re-checked by hunting for a code path where the opposite holds and confirming each at its true sink (model validation / DB constraint / controller guard / scope), distinguishing filters from enforcement (e.g. `MAX_SEAT_COUNT=50` is a matchmaking filter, `is_full?`=1000 is the cap). Result: **0 material overstatements.** Two low-severity notes: the RSpec `tree_type 16→0` expectation does not match the live `before_save` (coerces only `[17]`) — flagged at §2; and host-rejoin-by-token returning 409+body is now noted at §3. --- ## 1. Overview ### 1.1 What the server is The Forest server exposes one REST resource — `/api/v1/rooms` — that backs the entire PlantTogether ("Together" / group planting / Rooms) feature. A **host** creates a room, invites friends, starts the shared timer; **guests** join by room token or accept an invite. When the timer runs out or a participant gives up, the client calls **chop**, and the server computes the group tree's success/failure from participant `failed_at` timestamps against a `success_rate_threshold`. All three room types share **one table (`rooms`), one model (`Room`), one controller (`RoomsController`)**. They differ only in (a) how participants are populated and (b) a handful of type gates. Refs: `config/routes.rb:167-177`; `app/models/room.rb:1-350`; `app/controllers/api/v1/rooms_controller.rb:1-370`. ### 1.2 The one load-bearing constraint: the `available` scope Almost every mutating endpoint resolves the room through **`Room.available`** (often chained with `.joinable`). This is the single most important gate in the feature: ```ruby # app/models/room.rb:120-122 def self.available where(rooms: { start_time: nil }) .left_joins(:participants) .where("participants.user_id = rooms.host_user_id") end ``` A room is **available** iff **`start_time IS NULL` AND the host is still a participant**. Consequences: - Once a room **starts** (`start_time` set), it drops out of `available` → update / invite / reject / kick / start / leave all return **404**. - If the **host leaves** (host participant row destroyed), the room is no longer available → orphaned, un-actionable (`room_spec.rb:56-77` confirms `Room.available` returns nil and `host` is nil after host removal). - `joinable` = `where(room_type: JOINABLE_TYPES)` and `JOINABLE_TYPES = ['chartered']` (`room.rb:18,104-106`). So **only chartered rooms** can be joined-by-token, invited to, updated, started, kicked-from. shuttle_bus and birthday rooms bypass `joinable` entirely (see §1.3). ### 1.3 The three room types ```ruby # app/models/room.rb:17-20 enum room_type: { chartered: 0, shuttle_bus: 1, birthday_2019_random: 2 } JOINABLE_TYPES = ['chartered'] HOSTABLE_TYPES = ['chartered'] USER_CREATABLE_TYPES = ['chartered', 'birthday_2019_random'] ``` | Type | enum | User-creatable via API? | Joinable by token / invitable? | Population | |---|---|---|---|---| | `chartered` | 0 | **YES** | YES (`JOINABLE_TYPES`) | Host invites friends / token join | | `shuttle_bus` | 1 | **NO** — `room_type_check` → 400 | NO (not in `JOINABLE_TYPES`) | Server matchmaking: `ShuttleBusRoomService` random-assigns or batch-creates rooms hosted by `DRIVER_USER_ID`; `scheduled_start_time`; auto-start worker | | `birthday_2019_random` | 2 | **YES** (in `USER_CREATABLE_TYPES`) | NO (not in `JOINABLE_TYPES`) | See note below — vestigial on this codebase | - `room_type_check` (before_action on create+update) rejects any `room_type` **not** in `USER_CREATABLE_TYPES` with **400**; a nil `room_type` is allowed and defaults to `chartered` (enum default 0). So a client **can** create `birthday_2019_random` but **not** `shuttle_bus`. Refs: `rooms_controller.rb:7,361-366`; DB default `room_type default: 0` (`db/schema.rb`, rooms table). - **Correction / nuance vs iOS doc §1.2:** the iOS client hardcodes `chartered` and never creates the other two, but the *server* would accept a `birthday_2019_random` create. However such a room is **not joinable** (not in `JOINABLE_TYPES`) and **not startable/updatable/invitable** (update/invite/start use `.joinable` → 404). It gets a host participant (`after_create`) and can be chopped/left, but is otherwise a dead-end on the current code. No server-side matchmaking or special population exists for `birthday_2019_random` in this repo — the only distinct handling is client-side (invite cake image / notification title, per iOS doc §1.2). Ref: `rooms_controller.rb:59,114,285`. - **shuttle_bus** is fully server-driven: created only by `ShuttleBusRoomService.create_room` (`shuttle_bus_room_service.rb:178-201`), auto-started by `ScheduleShuttleBusWorker` (`app/workers/schedule_shuttle_bus_worker.rb`). See §9. ### 1.4 Data model & DB constraints `rooms` (`db/schema.rb`, rooms table): | Column | Type / default | Notes | |---|---|---| | `host_user_id` | bigint, **NOT NULL** | set from `current_user.id` on create (`rooms_controller.rb:355`) | | `tree_type` | integer, **NOT NULL** | validated present; must resolve to a `TreeType` gid | | `target_duration` | integer, **NOT NULL** | validated `inclusion: 600..10800` (10 min – 3 h) (`room.rb:5`) | | `success_rate_threshold` | integer, **default 100, NOT NULL** | validated `inclusion: 0..100` (`room.rb:8-11`) | | `room_type` | integer, **default 0, NOT NULL** | enum | | `is_success` | boolean, **default true** | computed by `judge_success` | | `start_time` / `end_time` | datetime, nullable | end auto-corrected on save (`room.rb:44-52`) | | `chopper_user_id` | bigint, nullable | set once, on failure only | | `scheduled_start_time` | datetime, nullable | shuttle_bus only; partial index `where room_type=1` | | `participants_count` | integer, default 0 | **counter_cache** maintained by `Participant belongs_to :room, counter_cache: true` (`participant.rb:3`) | | `notification_scheduled_at` | datetime, nullable | update-notification throttle marker | | `intl_id` | bigint, nullable, **unique index** | China-migration marker; `is_migrated_from_singapore? = intl_id.present?` (`room.rb:203-205`) | Model validations (`room.rb:5-15`): `target_duration` present & 600..10800; `tree_type` present; `success_rate_threshold` present & 0..100; `room_type` present; `intl_id` unique allow_nil. RSpec: `room_spec.rb` "intl_id" contexts confirm uniqueness + multiple-nil. `participants` (`db/schema.rb`, participants table): `user_id`, `room_id`, `failed_at` (nullable). Uniqueness: `validates_uniqueness_of :user, scope: :room` (`participant.rb:4`). `belongs_to :room` uses **counter_cache**. `default_scope { includes(:user) }` (`participant.rb:6`). ### 1.5 Endpoint map All under `/api/v1`, all `before_action :signed_in_user` (403 if not authenticated), all `skip_before_action :verify_authenticity_token`. Refs: `routes.rb:167-177`; `rooms_controller.rb:6-8`. | Verb | Path | Action | Room lookup scope | Role gate | |---|---|---|---|---| | POST | `/rooms` | create | — (new) | host = self | | GET | `/rooms` | index | `current_user.rooms` | participant | | GET | `/rooms/:id` | show | `find_by(id)` | must have_user? | | PUT | `/rooms/:id` | update | `available.joinable` | host only | | PUT | `/rooms/participate` | join | `available.joinable` by token | any signed-in | | PUT | `/rooms/:room_id/invite` | invite | `available.joinable` | host only | | PUT | `/rooms/:room_id/reject` | reject | `available.joinable` | non-host | | PUT | `/rooms/:room_id/start` | start | `available.joinable` | host only | | PUT | `/rooms/:room_id/kick` | kick | `available.joinable` | host only | | PUT | `/rooms/:room_id/leave` | leave | `available` (not joinable) | must have_user? | | PUT | `/rooms/:room_id/chop` | chop | `find_by(id)` (any state) | must have_user? | | GET | `/rooms/earthday_2018_summary` | earthday summary | event rooms | any signed-in | | GET | `/rooms/earthday_2018_result` | earthday result | event rooms | any signed-in | | GET | `/internal/rooms/:id` | internal show | `find_by(id)` | internal secret (`X-AUTH-TOKEN`) | | POST | `/shuttle_bus_queues` | enqueue ride | — | any signed-in | | GET | `/shuttle_bus_queues/:id` | queue status | `find_by(id)` | owner only | ### 1.6 Glossary - **available** — `start_time nil` AND host still a participant (`room.rb:120-122`). The master gate. - **joinable** — `room_type == 'chartered'` (`room.rb:18,159-161`). - **is_full?** — `participants.size >= (Constant 'ROOM_PARTICIPANT_LIMIT' || PARTICIPANT_LIMIT=1000)` (`room.rb:167-169`). Uniform across all types. - **chopper_user_id** — single scalar; the first participant whose chop pushes the room to failure (`room.rb:343`). Only set when the room ends in failure. - **judge_success** — recomputes `is_success` from `success_count/size` vs `success_rate_threshold` (`room.rb:211-214`); fired by participant `after_save` when `failed_at` changes. - **is_migrated_from_singapore?** — `intl_id.present?` (`room.rb:203-205`); suppresses notifications and `judge_success` side effects for China-migrated rows. - **DRIVER_USER_ID** — synthetic host for shuttle_bus rooms (1449808 prod / 1 non-prod) (`shuttle_bus_room_service.rb:8`). - **dummy user** — `user.dummy?` = migrated-but-incomplete OR destined-to-migrate (`user.rb:1211-1214`); serialized as `is_dummy_user:true` (`participant.rb:42`). ### 1.7 Feature map ```mermaid flowchart LR signin["signed_in_user (403 gate)"] --> create["POST /rooms (create)"] signin --> join["PUT /rooms/participate (token join)"] signin --> queue["POST /shuttle_bus_queues (bus matchmaking)"] create -->|"201"| host["Host in available room"] host --> invite["PUT /:id/invite"] invite -->|"INVITE push"| invitee["Invitee"] invitee -->|"accept"| join invitee -->|"decline"| reject["PUT /:id/reject (REJECT push)"] join -->|"200/409"| member["Participant in room"] host --> update["PUT /:id (reconfigure, pre-start)"] host --> kick["PUT /:id/kick"] member --> leave["PUT /:id/leave"] host --> start["PUT /:id/start (>=2 participants)"] queue -->|"usher worker"| busmatch["ShuttleBusRoomService assign/create"] busmatch --> scheduled["scheduled_start_time set"] scheduled -->|"ScheduleShuttleBusWorker at T-2s"| started start -->|"200"| started["Started room (timer runs)"] started --> chop["PUT /:id/chop (give up / finish)"] chop --> judge["judge_success -> is_success"] judge -->|"fail"| failed["is_success=false, chopper_user_id set, CHOP push"] judge -->|"still success"| ok["is_success stays true"] member --> poll["GET /rooms & GET /:id (polling)"] ``` --- ## 2. Sub-flow: Create room — `POST /api/v1/rooms` ### Summary Authenticated user creates a room. `room_type_check` gates the type; tree_type must exist and be owned (with a birthday-2024 free-plant exception window). On save, an `after_create` hook adds the host as the first participant and, for shuttle_bus only, schedules the auto-start worker. Returns 201 + room JSON (including `token`). ### Params `params.permit(:target_duration, :tree_type, :room_type).merge(host_user_id: current_user.id)` (`rooms_controller.rb:354-356`). **Only these three client params are honored.** `success_rate_threshold`, `start_time`, etc. are not settable on create. ### Auth / gates (in order) 1. `signed_in_user` → **403** if not authenticated (`sessions_helper.rb:55-63`; spec `rooms_controller_spec.rb:196-201`). 2. `room_type_check` → **400** if `room_type` present and ∉ `{chartered, birthday_2019_random}` (`rooms_controller.rb:361-366`; spec `:144-149` shuttle_bus→400). 3. tree_type resolution → **423** if `TreeType.find_by(gid:)` nil (`:32-36`; spec `:157-168` missing/`-1`→423). 4. ownership → **423** unless `current_user.tree_types.include?(tree_type)`, **unless** birthday-2024-free-plant window active (`:38-49`; spec `:169-177`). ### Happy path 1. `Room.new(room_create_params)` (`:30`). 2. tree_type exists + owned (or free-plant window) (`:32-49`). 3. `before_save`: if `tree_type ∈ [17]` → coerce to 0; auto-correct `end_time` bounds (`room.rb:39-53`). Spec `:178-193` banned tree_type 16→ actually coerced only for 17; 16 test expects tree_type 0 (note: `[16,17]` ban is commented out in `host_should_have_tree_type_unlocked`; the live `before_save` coerces only 17 — see Edge cases). 4. `room.save` → validations pass. 5. `after_create` (`room.rb:55-82`): `participants.create(user_id: host_user_id)` — host becomes participant (counter_cache → participants_count=1). Then joint-activity threshold override (see Edge cases). 6. `after_commit :schedule_shuttle_bus on: :create` (`room.rb:95-102`): no-op unless `shuttle_bus?`. 7. Render `room.as_json` (includes `token`) status **201** (`:51-52`). `as_json` injects `token = gen_code(id)` (`room.rb:145-153`). RSpec: `rooms_controller_spec.rb:118-142` — 201, `Room.count +1`, response `id`/`token`. ### Error branches | Condition | Status | Body | |---|---|---| | not signed in | 403 | `''` | | room_type not user-creatable (e.g. shuttle_bus) | 400 | `''` | | tree_type missing / nonexistent | 423 | `''` | | tree_type not owned (outside free window) | 423 | `''` | | validation fail (e.g. target_duration missing, out of 600..10800) | 422 | `''` | RSpec: target_duration missing → 422 (`:150-156`). ### Side effects - Host participant row created (`room.rb:56`); `participants_count → 1`. - **Joint-activity threshold override** (`room.rb:64-80`): during `SecretConstant` `joint_activity_plant_together_start/end_time` window, if `host_user_id == joint_activity_user_id` (719620 CN / 16883055 global) **and** host email `== 'growth@seekrtech.com'`, set `success_rate_threshold` from `SecretConstant joint_activity_plant_together_threshold` (default 50). - shuttle_bus only: `ScheduleShuttleBusWorker.perform_at(scheduled_start_time - 2s, id)` (`room.rb:101`) — but user API cannot create shuttle_bus, so this path fires only from `ShuttleBusRoomService`. ### Edge cases - **tree_type 17** → silently coerced to 0 before save (`room.rb:40-42`). Spec `:178-193` uses gid 16 and asserts tree_type becomes 0 — **the spec's "banned tree_type" expectation (16→0) does not match the live `before_save`, which only coerces 17.** Flag: spec may rely on other config or be stale; live code coerces only `[17]`. - **birthday-2024 free plant window** → ownership check skipped for `birthday_2024_free_plant?` gids inside `[FOREST_2024_BIRTHDAY_START_TIME, ...END_TIME]` (Constants, defaults 2024-05-23..05-31) (`:38-45`). - **birthday_2019_random create** → accepted (400 gate passes) but resulting room is not joinable/startable (§1.3). - **end_time auto-correction** applies on every save if both times present (`room.rb:44-52`). ### iOS cross-reference iOS create sends exactly `{tree_type, target_duration, room_type:"chartered"}` (iOS doc §2, `TogetherDataManager.m:76-82`) → matches server's 3 permitted params. Server's ownership 423 → iOS never hits it for chartered because host list is filtered to owned trees. Server accepts birthday create; iOS has no path for it (client-only distinction). --- ## 3. Sub-flow: Join by token — `PUT /api/v1/rooms/participate` ### Summary Guest submits a room `token`. Region-checked, decoded to a room id, resolved through `available.joinable`. Rejoin (already a participant) returns 409 **with** the room JSON so the client can resume. Ownership, capacity, then `add_participant` (locked). Returns room+participants JSON on 200. ### Params `token` (collection route, no `:id`) (`routes.rb:168`; `rooms_controller.rb:164`). ### Auth / gates & branch order (`rooms_controller.rb:163-202`) 1. `signed_in_user` → **403**. 2. `token.blank?` → **400** (spec `:545-550`). 3. `Room.from_current_region(token)` false → **498** (region suffix mismatch) (`:171`; `room_code.rb:25-28`). 4. `Room.find_available_joinable_with_token(token)` nil → **404** (`:173-177`; `room.rb:124-127`). Covers unknown token, started room, non-chartered, host-gone room. Spec `:536-543`. 5. already a participant → **409 + room JSON** `as_json(methods: :participants)` (`:179-183`; spec `:520-534`). The `participants.find_by(user: current_user)` check matches **any** existing member, so a host re-participating by token also gets 409+body (not a special case). 6. tree_type `owners_only_in_rooms?` and not owned → **483** (`:185-189`; spec `:460-471`). 7. `is_full?` → **423** (`:191-193`; spec `:504-518` with stubbed `PARTICIPANT_LIMIT=10`). 8. `add_participant(current_user)` nil → **422**; else **200 + room JSON** (`:196-201`). ### Token codec (`lib/room_code.rb`) - Feistel cipher over the numeric id + region suffix (`SG` global / `SC` china) (`room_code.rb:14-52`). `from_current_region` checks last 2 chars == region code. - `retrieve_number(token)` → room id; `gen_code(id)` → token. Case-insensitive, stripped (`:26,42`). ### `add_participant` (non-shuttle path) (`room.rb:258-291`) - `with_lock` (row lock) → re-check `is_available?` (raise if not) → `is_full?` (raise if full) → `participants.new(user:).save!`. - On success (outside lock): `notify_users` (UPDATE push to participants) (`:284`). - Any exception → returns nil → controller 422. ### Success / error table | Condition | Status | Body | |---|---|---| | joined | 200 | room + participants JSON | | already participant | 409 | room + participants JSON | | blank token | 400 | `''` | | wrong region | 498 | `''` | | not found / not available / not chartered | 404 | `''` | | tree owners_only & not owned | 483 | `''` | | full | 423 | `''` | | add_participant failed (race) | 422 | `''` | ### Side effects - Participant row + `participants_count++`; `notify_users` → `TogetherNotificationService UPDATE` (unless size > 50 or migrated) (`room.rb:216-236,284`). ### iOS cross-reference (join error mapping — iOS doc §3) | Server | iOS mapping | |---|---| | 200 | parse room, `DidJoinRoom` | | 409 | rejoin if `shouldRejoinTogetherRoomAutomatically` else `alreadyJoined` | | 400 | `invalidToken` → `fail_message_invalid_room_code` | | 404 | `roomNotFound` | | 423 | `roomFull` | | 483 | `treeTypeNotOwned` | | 498 | `roomInOtherServer` | | 403 | `notAuthenticated` | **Client↔server match is exact.** Note: server 409 **returns the room body**; iOS relies on that for silent rejoin (`TogetherDataManager.swift:345-351`). --- ## 4. Sub-flow: Invite — `PUT /api/v1/rooms/:room_id/invite` ### Summary Host invites a **friend** to an available chartered room. Sends an `INVITE` push to the invitee. **Does not add a participant and does not check capacity** — invite only fires a notification. ### Params `user_id` (`rooms_controller.rb:108-112`). ### Branch order (`rooms_controller.rb:108-139`) 1. `signed_in_user` → 403. 2. `room_type_check` does **not** apply (only create/update). 3. missing `user_id` → **400** (spec `:361-366`). 4. `available.joinable.find_by(id: room_id)` nil → **404** (spec `:375-383` started room→404). 5. not host → **401** (spec `:385-392`). 6. `current_user.friends.find_by(id: user_id)` nil → **404** (stranger→404, spec `:368-373`). 7. `room.invite(host, user)` → **200**, else **422**. ### `room.invite` (`room.rb:238-246`) - Returns false if `!is_available_joinable?`. - Sends `TogetherNotificationService INVITE` to `[to_user.id]` with `from_user.id`. - rescue → false. ### Key facts - **No capacity check on invite** (confirmed — seed fact holds). A host may invite beyond `PARTICIPANT_LIMIT`; the cap is enforced only at join (`is_full?` → 423). - friend = mutual follow (`user.rb:463-465` `following.where(id: followers)`). Non-friends → 404. ### iOS cross-reference iOS sends one PUT per selected friend, body `{user_id}` only (iOS doc §4). Server matches. iOS has a dead >5 cap; server has no invite cap either — consistent (no cap anywhere). --- ## 5. Sub-flow: Reject invitation — `PUT /api/v1/rooms/:room_id/reject` ### Summary Invitee declines. Sends a `REJECT` push to the host. Host cannot reject own room. ### Branch order (`rooms_controller.rb:141-161`) 1. `signed_in_user` → 403 (spec `:431-436`). 2. `available.joinable.find_by(id)` nil → **404** (started→404, spec `:421-429`). 3. **host** calling → **401** (`:148-151`) — inverse of other endpoints. 4. `room.reject_invite(current_user)` → **200** else **422** (spec `:405-418`). ### `room.reject_invite` (`room.rb:248-256`) - false if `!is_available_joinable?`; else `TogetherNotificationService REJECT` to `[host_user_id]` with `from_user.id`. ### iOS cross-reference iOS decline → `PUT /rooms/{id}/reject` fire-and-forget, clears pending+room on both success/failure (iOS doc §5, `TogetherDataManager.m:442-456`). Server just pushes REJECT to host. Match. --- ## 6. Sub-flow: Show & Index (polling) — `GET /rooms/:id`, `GET /rooms` ### 6.1 Show (`rooms_controller.rb:204-227`) - `find_by(id)` (any state — not scoped to available) → nil → **404** (spec `:611-616`). - `!has_user?(current_user)` → **401** (non-participant, spec `:601-609`). - `detail` param → `as_json(include: {participants: {except: [:id,:room_id,:created_at,:updated_at]}})`; else `as_json` (room only, no participants) (`:216-226`). Spec `:560-599`. - **401 for non-participants** — a started room is still visible to its participants via show (unlike the `available`-scoped mutations). ### 6.2 Index (`rooms_controller.rb:10-27`) - `current_user.rooms.includes(:participants)` = rooms the user participates in (`user.rb:57` `has_many :rooms, through: :participants`). - `from_date` / `to_date` filter on `rooms.updated_at`; unparseable date → **400** (spec `:81-86,102-107`). - Renders `as_json(methods: :participants)` status 200 (spec `:34-116`). - Client polls this with `from_date` high-water mark for delta sync (iOS `DataSyncManager` read-model; kit `getRooms` from=lastSync to=now — iOS doc §10). ### Participant serialization (`participant.rb:26-56`) `as_json` / `serializable_hash` inject: `avatar` (cloudflared or ""), `name`, `is_dummy_user` (as_json only), `is_host` (`room.host_user_id == user_id`). `serializable_hash` overridden so the `include:` form works (Rails issue rails/rails#2200, noted `rooms_controller.rb:220-221`). --- ## 7. Sub-flow: Start — `PUT /api/v1/rooms/:room_id/start` ### Summary Host starts the shared timer. Requires ≥2 participants. Sets `start_time = now`, `end_time = start + target_duration`. ### Branch order (`rooms_controller.rb:284-307`) 1. `signed_in_user` → 403 (spec `:800-805`). 2. `available.joinable.find_by(id)` nil → **404** (already-started→404, spec `:790-798`). 3. not host → **401** (participant→401, spec `:776-788`). 4. `is_only_one_participant?` (`participants.size <= 1`) → **423** (spec: min-2 implied; RoomTooVacant). 5. `room.start(Time.now)` → **200 + room JSON**, else **422**. ### `room.start` (`room.rb:316-325`) ```ruby with_lock do next false if is_started? # idempotency next false if is_only_one_participant? next update(start_time:, end_time: start_time + target_duration.seconds) end ``` - **Double guard** on start_time and participant count inside the lock. Confirms seed fact: **min 2 participants** (`room.rb:319`; `rooms_controller.rb:296-300`). - `end_time = start_time + target_duration` seconds. Spec `:753-774` — 200, `is_started?` true, returns `room_type:'chartered'`. ### Side effects - `after_update` (`room.rb:84-93`): `is_success` unchanged → `notify_users` (UPDATE push) unless `notification_scheduled_at` was the only change. ### iOS cross-reference (iOS doc §12.1) | Server | iOS | |---|---| | 200 | InSession, `DidStartPlanting` | | 404 | RoomAlreadyStarted → reload | | 401 | InappropriateRole (`fail_message_not_a_host`) | | 423 | RoomTooVacant (`fail_message_participant_not_enought`) | Match exact. --- ## 8. Sub-flow: Leave / Kick ### 8.1 Leave — `PUT /rooms/:room_id/leave` (`rooms_controller.rb:229-246`) 1. `signed_in_user` → 403. 2. `Room.available.find_by(id)` — **note: `.available` only, NOT `.joinable`** → nil → **404** (started→404, spec `:655-663`). 3. `!has_user?` → **401** (non-participant, spec `:645-653`). 4. `remove_participant(current_user)` → **200** else **422** (spec `:626-644`). ### 8.2 Kick — `PUT /rooms/:room_id/kick` (`rooms_controller.rb:249-282`) 1. `signed_in_user` → 403. 2. missing `user_id` → **400** (spec `:680-685`). 3. `available.joinable.find_by(id)` nil → **404** (spec `:738-743`). 4. not host → **401** (participant→401, spec `:725-736`). 5. target `User.find_by(id)` nil OR not in room → **410** (stranger/nonexistent→410, spec `:711-722`). 6. target is host → **423** (cannot kick host, spec `:700-710`). 7. `remove_participant(user)` → **200** else **422** (spec `:686-699`). ### `remove_participant` (`room.rb:293-314`) ```ruby with_lock do raise "not available" unless is_available? participant = participants.find_by(user:); return false if nil participant.destroy! end TogetherNotificationService UPDATE -> [user.id] # tell the removed user notify_users # tell the rest ``` - **Host self-leave is allowed** (leave endpoint, `has_user?` true). This destroys the host participant → room leaves `available` scope → orphaned but **not destroyed**. - Kick **cannot** remove the host (423 guard). So the only way a host exits is self-leave. ### iOS cross-reference (iOS doc §12.2–12.3) - iOS treats host "Cancel" as "destroy room" — **MISMATCH**: server does **not** destroy the room on host leave; it just removes the host participant, leaving an orphaned unavailable row. The room persists in DB (visible via show to remaining participants, but un-actionable). Flag for kit refactor. - Kick: iOS treats 200 **and 410** as success (410 = already gone) (`TogetherDataManager.m:459-481`). Server returns 410 for stranger/nonexistent/not-in-room. Match. - Leave failure: iOS keeps user in room on 403/404 (iOS doc §12.2). Server 404 = room no longer available; server never mutated. Consistent. --- ## 9. Sub-flow: Chop + success/failure computation — `PUT /rooms/:room_id/chop` ### Summary A participant reports give-up/finish with an `end_time`. The server records the participant's `failed_at`, which triggers `judge_success`. If the room drops below the success threshold, `is_success=false`, `chopper_user_id` is set, and a `CHOP` push fires. Idempotent once a chopper is recorded. ### Branch order (`rooms_controller.rb:309-331`) 1. `signed_in_user` → 403 (spec `:861-866`). 2. missing `end_time` → **400** (spec `:813-818`). 3. `Room.find_by(id)` — **any state, NOT scoped** → nil → **404** (spec `:844-849`). 4. `!has_user?` → **401** (stranger→401, spec `:852-860`). 5. `room.chop(current_user, end_time)` → **200** else **422** (not-started→422, spec `:837-842`). ### `room.chop` (`room.rb:327-349`) ```ruby with_lock do next false unless is_started? # not started -> 422 next true if chopper_user_id.present? # idempotent: already failed participant = participants.find_by(user:); next false if nil participant.chop(end_time) # sets failed_at -> triggers judge_success if is_success? # still meets threshold notify_users; next true end result = update(is_success: false, end_time:, chopper_user_id: user.id) TogetherNotificationService CHOP -> all participant user_ids (from user.id) next result end ``` ### Success computation (`room.rb:211-214`, `participant.rb:9-16,62-64`) - `participant.chop(time)` → `update(failed_at: time)`. - Participant `after_save`: if `saved_change_to_failed_at?` → `room.judge_success`; then `room.touch` (skipped entirely for migrated rooms) (`participant.rb:9-16`). - `judge_success`: ```ruby is_success = MAX_SUCCESS_RATE_THRESHOLD(100) * participants.success(true).count / participants.size >= success_rate_threshold save ``` - `participants.success(true)` = `where(failed_at: nil)` (`participant.rb:18-24`) — the still-alive participants. - **Integer division** (Ruby `/` on ints). e.g. 2 of 3 alive @ threshold 60: `100*2/3 = 66 >= 60` → success. RSpec `room_spec.rb:118-145` confirms: 3 participants, threshold 60 — 0 fail → success, 1 fail → success (66≥60), 2 fail → fail (33<60). - **Default threshold 100**: at 100, one failure among N makes `100*(N-1)/N < 100` → fail. So a default room fails on the first give-up (any participant). `chopper_user_id` = that first failing participant. ### `is_success` after_update side effect (`room.rb:84-93`) - When `is_success` flips to false: for each associated `plant`, set `is_success:false`, copy `start_time/end_time`, `total_focus_duration = (end_time - start_time).to_i` (floor). RSpec `room_spec.rb` "plants total_focus_duration sync on room failure" confirms integer floor (1499.7→1499). - Otherwise `notify_users`. ### Idempotency & concurrency - Once `chopper_user_id` present, further chop → `next true` (200, no-op). First chopper wins. - `with_lock` serializes concurrent chops on the same room. - Note: a chop that keeps the room successful still records the participant's `failed_at` and sends UPDATE via `notify_users` — but does **not** set chopper or flip is_success. ### iOS cross-reference (iOS doc §9/§10) - Client computes local display approximations (`maxNumberFailedParticipantsAllowed`, `currentSuccessRate`); **authoritative computation is server `judge_success`** (iOS open question §11.1 — answered here). - iOS `chopper_user_id` single scalar matches server single scalar; multiple participants can have `failed_at` but only one `chopper_user_id` (the threshold-crossing one). Confirms iOS glossary. - chop 401/404 handling on iOS (drop room assoc) maps to server 401 (not participant) / 404 (no room). 422 (not started) → iOS retries via needsChopper. --- ## 10. Sub-flow: Shuttle-bus matchmaking (`ShuttleBusRoomService`, workers) ### 10.1 Enqueue — `POST /api/v1/shuttle_bus_queues` (`shuttle_bus_queues_controller.rb`) 1. `signed_in_user` → 403. 2. `ShuttleBusRoomService.is_available_for_boarding?` false → **410** (boarding window closed). 3. Create `ShuttleBusQueue(user:)` → nil → 422. 4. `ShuttleBusUsherWorker.perform_async(sbq.id)` → **201 + sbq JSON**. `GET /shuttle_bus_queues/:id`: not found → 404; not owner → 403; else 200 (`processed`, `room_id`). ### 10.2 Usher worker (`app/workers/shuttle_bus_usher_worker.rb`, queue `critical`) - Loads pending sbq → `ShuttleBusRoomService.new_request_ride_for(user)` → updates `sbq.room, processed:true` → posts `SHUTTLE_BUS_QUEUE_PROCESSED` push (postoffice) with `room_id` (nil if no ride). ### 10.3 `new_request_ride_for` (`shuttle_bus_room_service.rb:111-130`) - Query: `Room.available.shuttle_bus` with `scheduled_start_time` in `(now, now + doors_open + 30s]`, `participants_count < MAX_SEAT_COUNT(50)`, ordered by `participants_count DESC` (fill fullest first). - If Sidekiq `shuttlebus` queue depth > 500 → `limit(10).shuffle` (load-shed). - Iterate rooms, `add_participant(user, is_shuttle_bus=true)`; first success returns that room. ### 10.4 `request_ride_for` / `assign_room` / `create_room` (`:132-201`) - `assign_room`: `Room.available.shuttle_bus`, `scheduled_start_time > now`, `participants_count < 50`, `ORDER BY RANDOM()`; per-room in-memory `participants.size >= 50` recheck; `add_participant(_, true)`. - `create_room` (`:178-201`): computes `next_shuttle_bus_time`; bails if none or not boarding; `scheduled_start_time = bus_time + doors_open + 30s`; **batch-creates** `max(pending_jobs/50, 1)` rooms with `tree_type:32, room_type:'shuttle_bus', host_user_id:DRIVER_USER_ID, success_rate_threshold: BUS_SUCCESS_RATE_THRESHOLD(80, Constant-overridable)`. Adds the requesting user to the first created room. - Lock via Constant `shuttle_bus_create_lock` `with_lock` to avoid create stampede (`:138-154`). ### 10.5 Auto-start — `ScheduleShuttleBusWorker` (`app/workers/schedule_shuttle_bus_worker.rb`, queue `low`) - Scheduled by `room.schedule_shuttle_bus` `after_commit :create` at `scheduled_start_time - 2s` (`room.rb:95-102`). - `perform`: load room; return if nil or already started; `room.start(scheduled_start_time)`. - (There is a parallel dormant `ScheduleShuttleBusJob` ActiveJob with identical logic, `app/jobs/schedule_shuttle_bus_job.rb`; the **Sidekiq worker** is the one wired via `perform_at`.) ### 10.6 `MAX_SEAT_COUNT = 50` — matchmaking filter, NOT a cap Confirms seed fact: `MAX_SEAT_COUNT` (`:4`) only filters candidate rooms in matchmaking queries. The cap enforced at join is `is_full?` — `participants.size >= (Constant['ROOM_PARTICIPANT_LIMIT'] || 1000)` (`room.rb:167-169`): a **runtime cloud-config value, default 1000** (see §10.8), not a fixed constant. A shuttle_bus room could theoretically exceed 50 via `add_participant` (which checks `is_full?`, not `MAX_SEAT_COUNT`) — matchmaking just stops *routing* new riders to rooms at ≥50. ### 10.7 Constants (`shuttle_bus_room_service.rb:4-22`) `BUS_INTERVAL` 4h prod/15m else; `DOORS_OPEN_INTERVAL` 5m; `WAIT_FOR_START_INTERVAL` 30s; `BUS_DURATION` 10m; `BUS_SUCCESS_RATE_THRESHOLD` 80; `DRIVER_USER_ID` 1449808 prod/1; `PENDING_JOB_THRESHOLD` 500; `BATCH_CREATE_ROOM_NUMBER` 10. All time/rate values overridable via `Constant` keys (`dynamic_config`, `:25-50`). ### 10.8 Dynamic / cloud config — `Constant` & `SecretConstant` Many "constants" in this spec are **runtime-tunable via a DB-backed config table, changed by ops with no deploy** — Ruby literals are only fallbacks. Two tables: - **`Constant`** (`constant.rb`) — key/value rows, read as `Constant.find_by(key:)&.value || `. On every write, `after_commit :write_to_file` **exports the whole table to Cloudflare R2/S3 as `constants.json` (+ gzip), public with `max-age`** — this is the "cloud config" surface. The client/CDN can read the exported JSON directly. - **`SecretConstant`** (`secret_constant.rb`) — same pattern, not exported (server-only, e.g. joint-activity window/threshold at `room.rb:64-80`). **`Exp?`** = exported to public `constants.json` (R2/S3). `Constant` rows are exported (client/CDN-readable); `SecretConstant` rows are **not** (server-only). **Complete inventory — every cloud-configurable value that affects the Rooms / PlantTogether feature.** Ruby values shown are fallbacks used only when the row is absent; the live value is whatever ops set in the DB and is **not knowable from code alone**. *Enforcement / limits* | # | Behavior | Key | Table | Exp? | Default | Source | Effect if changed | |---|---|---|---|---|---|---|---| | 1 | Room participant cap (`is_full?`) | `ROOM_PARTICIPANT_LIMIT` | Constant | ✅ | **1000** | `room.rb:168` | Join `is_full?` → **423** at a different count. Uniform across all room types. | *Ownership (483) gate window — toggles whether the tree-not-owned check is skipped* | # | Behavior | Key | Table | Exp? | Default | Source | Effect if changed | |---|---|---|---|---|---|---|---| | 2 | Birthday-2024 free-plant window **start** | `FOREST_2024_BIRTHDAY_START_TIME` | Constant | ✅ | `2024-05-23T02:00:00Z` | `rooms_controller.rb:40,82`; `plant.rb:90` | Inside `[start,end]`, ownership check is **skipped** for `birthday_2024_free_plant?` tree types on **both create and join** — i.e. the **483 gate is disabled** for those species during the window. | | 3 | Birthday-2024 free-plant window **end** | `FOREST_2024_BIRTHDAY_END_TIME` | Constant | ✅ | `2024-05-31T02:00:00Z` | `rooms_controller.rb:41,83`; `plant.rb:91` | (as above) | *Joint-activity success-threshold override (special host in a window)* | # | Behavior | Key | Table | Exp? | Default | Source | Effect if changed | |---|---|---|---|---|---|---|---| | 4 | Joint-activity window **start** | `joint_activity_plant_together_start_time` | SecretConstant | ❌ | — (nil ⇒ inactive) | `room.rb:64` | Window in which the override applies. | | 5 | Joint-activity window **end** | `joint_activity_plant_together_end_time` | SecretConstant | ❌ | — (nil ⇒ inactive) | `room.rb:65` | (as above) | | 6 | Joint-activity success threshold | `joint_activity_plant_together_threshold` | SecretConstant | ❌ | 50 | `room.rb:76` | When the special joint-activity host (`719620` CN / `16883055` global, email `growth@seekrtech.com`) creates a room in-window, room `success_rate_threshold` is set from this. | *Shuttle-bus (all read via `dynamic_config` → `Constant`)* | # | Behavior | Key | Table | Exp? | Default | Source | Effect if changed | |---|---|---|---|---|---|---|---| | 7 | Event window **start** | `forest_2018_earthday_start` | Constant | ✅ | — (nil ⇒ **no bus rooms**) | `shuttle_bus_room_service.rb:53,87` | Nil disables the whole shuttle-bus event (no rooms scheduled/created). | | 8 | Event window **end** | `forest_2018_earthday_end` | Constant | ✅ | — (nil ⇒ inactive) | `shuttle_bus_room_service.rb:54,88` | (as above) | | 9 | Bus ride duration | `forest_2018_earthday_bus_duration` | Constant | ✅ | 10 min | `shuttle_bus_room_service.rb:15,191` | Sets created room `target_duration`; feeds window math. | | 10 | Bus period / interval | `forest_2018_earthday_bus_period` | Constant | ✅ | 4 h prod / 15 min else | `shuttle_bus_room_service.rb:16` | Spacing between departures. | | 11 | Bus success-rate threshold | `forest_2018_earthday_bus_success_rate` | Constant | ✅ | 80 | `shuttle_bus_room_service.rb:17,191` | `success_rate_threshold` of created bus rooms. | | 12 | Boarding→departure (doors-open) duration | `forest_2018_earthday_bus_boarding_to_departure_duration` | Constant | ✅ | 5 min | `shuttle_bus_room_service.rb:18` | Boarding window before scheduled start. | | 13 | Create-stampede lock | `shuttle_bus_create_lock` | Constant | ✅ | — | `shuttle_bus_room_service.rb:138` | Row used for `with_lock` to serialize bus-room creation. | **Hardcoded, NOT configurable** (do not present as tunable): `MAX_SEAT_COUNT=50` (matchmaking filter, §10.6), `WAIT_FOR_START_INTERVAL=30s`, `DRIVER_USER_ID`, `PENDING_JOB_THRESHOLD=500`, `BATCH_CREATE_ROOM_NUMBER=10`, `MAX_PARTICIPANT_LIMIT_FOR_PUSHING_UPDATE_NOTIFICATION=50`. **Out of scope (not Rooms behavior):** `FOREST_2019/2020/2021/2022_*` birthday/new-year/christmas windows (`achievement_concern.rb`) are achievement-tracking, not room gating (the `birthday_2019_random` room type is a bare enum with no config gate); `LATEST_BUILD`, boost/merge-tree, and anonymous-unity keys are unrelated subsystems. **Bottom line:** none of keys #1–#13 are seeded in the repo → for every row above, the repo value is a *default*, and the live effective value is set in the production DB (and, for `Constant` rows, mirrored to `constants.json` on Cloudflare). Any statement in this spec that cites one of these numbers as fixed is describing the fallback only. ### 10.9 Earthday-2018 endpoints (legacy event reporting) - `earthday_2018_summary` (`rooms_controller.rb:333-341`): every 4h-on-the-hour returns **410**; else summary hashes (`participating_status_summary`, `participants_count_summary`, `real_trees_summary`) from `ShuttleBusRoomService`. - `earthday_2018_result` (`:343-351`): time-gated distinct participant count over event rooms. Legacy; kept for completeness. ### iOS cross-reference iOS has no client-side matchmaking (iOS doc §1.2); all shuttle_bus population is server-side — confirmed. `scheduled_start_time` countdown, dummy users, threshold are server-set; client only displays (iOS doc §12.10). shuttle_bus header messaging keyed on room state, not scheduled_start_time. --- ## 11. Notifications (`TogetherNotificationService`, `TogetherNotificationWorker`) ### Dispatch `TogetherNotificationService#send(uids, room_id, state, user_id=nil)` (`together_notification_service.rb:14-22`): - States: `UPDATE`, `CHOP`, `INVITE`, `REJECT`. Unknown state → no-op. - **In test env: no-op** (`unless Rails.env.test?`) — so RSpec never dispatches pushes. - Else `TogetherNotificationWorker.perform_async(...)` (rescued). ### Worker (`app/workers/together_notification_worker.rb`, queue `default`) - Returns in test env; loads room (nil→return); loads user if `user_id` (nil→return). - Builds `custom` payload: `type, room_id, room_token, user_id, name, avatar, target_duration, tree_type, room_type` (`:24-34`). - If `uids` nil → resets `notification_scheduled_at:nil` and broadcasts to all `participating_users` (`:47-50`). - POSTs to postoffice `#{BASE_URL}/projects/#{PROJECT_ID}/users/batch_notifications` in `find_in_batches` (`:52-57`). ### Throttling & suppression (`room.rb:216-236`) `notify_users`: - **Skip if `participants.size > 50`** (`MAX_PARTICIPANT_LIMIT_FOR_PUSHING_UPDATE_NOTIFICATION`, `room.rb:24,218`) — big rooms send no update pushes. - Skip if migrated-from-singapore (`:225`). - Throttle: only if `notification_scheduled_at` nil or `< 5s ago`; sets `notification_scheduled_at = now` then dispatches UPDATE (`:228-231`). ### Who gets what | Event | State | Recipients | user_id (actor) | |---|---|---|---| | join / leave (rest) / start / update / successful-chop | UPDATE | all participants (uids nil) | — | | invite | INVITE | `[invitee.id]` | host | | reject | REJECT | `[host_user_id]` | rejector | | removed participant (leave/kick) | UPDATE | `[removed.id]` then all | — | | failing chop | CHOP | all participant user_ids | chopper | ### iOS cross-reference (iOS doc §4, §5, §11) Payload fields map to iOS `TogetherInvitationNotificationPayload` (duration, tree_type→Species, name, room_token, room_id, room_type) — server sends exactly these. iOS invite gating (`pendingInvitation`, `TOGETHER_INVIATION_BLOCKED`) is client-side; server always dispatches. **Note:** `TogetherNotificationJob` (ActiveJob variant, `app/jobs/together_notification_job.rb`) omits `room_type` and posts per-user, not batch — it is the older/dormant path; the live path is the Sidekiq **Worker** (batch, includes room_type). --- ## 12. Internal endpoint — `GET /api/v1/internal/rooms/:id` - `before_action :internal_validate` (`internal_system_helper.rb:3-20`): missing `INTERNAL_SYSTEM_SECRET` env → **501**; missing `X-AUTH-TOKEN` header → **401**; mismatch (secure_compare) → **403**. - `show`: missing id → 400; room nil → 404; else `room.as_json(methods: :participants)` 200 (`internal/rooms_controller.rb:10-24`). - No `signed_in_user`; server-to-server only. Not part of the client contract. Also note the screenshot/staging-only internal route `PUT /internal/users/:user_id/screenshot_customs/room_add_all_friends` (`routes.rb:238`) — staging fixture helper, out of scope for the live feature. --- ## 13. State machine (chartered room lifecycle) ```mermaid stateDiagram-v2 [*] --> Available : "POST /rooms 201 (after_create adds host)" Available --> Available : "invite / reject (push only) · update (reconfigure) · join (participate) · leave/kick guest" Available --> Orphaned : "host leave (host participant destroyed)" Available --> Started : "PUT /start 200 (>=2 participants, start_time+end_time set)" Started --> Started : "chop keeps success (failed_at recorded, is_success stays true)" Started --> Failed : "chop crosses threshold (is_success=false, chopper_user_id, CHOP push, plants updated)" Started --> Ended : "end_time passes (wall-clock; success stays true)" Failed --> [*] Ended --> [*] Orphaned --> [*] note right of Available Mutations require Room.available (start_time nil AND host present). Only chartered is joinable/invitable/startable/updatable. end note ``` ### Transition table (chartered) | From | Endpoint | Guard | To | On fail | |---|---|---|---|---| | — | POST /rooms | type ok, tree owned | Available | 400/422/423 | | Available | PUT /:id (update) | host, pre-start, tree rules | Available (reconfigured) | 404/401/423/422 | | Available | PUT /participate | region, chartered, not-full, tree owned | Available (+participant) | 498/404/409/483/423/422 | | Available | PUT /invite | host, friend | Available (INVITE push) | 400/404/401/422 | | Available | PUT /reject | non-host | Available (REJECT push) | 404/401/422 | | Available | PUT /leave (guest) | participant | Available (−participant) | 404/401/422 | | Available | PUT /leave (host) | participant | **Orphaned** | 404/422 | | Available | PUT /kick | host, target≠host, target in room | Available (−participant) | 400/404/401/410/423/422 | | Available | PUT /start | host, ≥2 participants | Started | 404/401/423/422 | | Started | PUT /chop | participant, started | Started / Failed | 400/404/401/422 | | any (participant) | GET /:id, GET / | has_user? | (read) | 404/401/400 | State predicates: `is_available?` (`room.rb:183-185`), `is_started?` (`:179-181`), `is_ongoing?` (`:191-193`), `is_full?` (`:167-169`). RSpec `room_spec.rb` verifies each across the with/without start_time and host-removed contexts. --- ## 14. Reconciliation of seed facts | Seed fact | Verdict | Evidence | |---|---|---| | enum chartered:0, shuttle_bus:1, birthday_2019_random:2 | ✅ confirmed | `room.rb:17` | | `PARTICIPANT_LIMIT = 1000`, override via Constant `ROOM_PARTICIPANT_LIMIT` (none seeded) | ✅ confirmed | `room.rb:23,167-169` | | `is_full? = size >= limit`, uniform for all types | ✅ confirmed | `room.rb:167-169`; spec `:504-518` | | Prior "500 max" claim | ❌ FALSE — real hard cap **1000** (Constant-overridable); shuttle matchmaking filter is 50 | `room.rb:23`; `shuttle_bus_room_service.rb:4` | | Join full → 423 | ✅ confirmed | `rooms_controller.rb:191-193` | | Invite does NOT check capacity | ✅ confirmed | `rooms_controller.rb:108-139` (no `is_full?`) | | shuttle_bus `MAX_SEAT_COUNT = 50` is a matchmaking filter, not a cap | ✅ confirmed | `shuttle_bus_room_service.rb:4,112,164,167` | | Min 2 participants to start | ✅ confirmed | `room.rb:319`; `rooms_controller.rb:296-300` | | `MAX_PARTICIPANT_LIMIT_FOR_PUSHING_UPDATE_NOTIFICATION = 50` (new) | ✅ found | `room.rb:24,218` — rooms >50 send no UPDATE pushes | --- ## 15. Client ↔ server mismatches & assumptions (flag for kit refactor) 1. **Host "cancel" ≠ room destroy.** iOS models host cancel as destroying the room (iOS doc §12.2). Server `leave` merely removes the host participant → room becomes **Orphaned** (unavailable, still in DB). No cleanup job destroys it. The kit refactor should not assume a room disappears when the host leaves. Ref: `rooms_controller.rb:229-246`; `room.rb:293-314`. 2. **birthday_2019_random is creatable but inert.** `USER_CREATABLE_TYPES` includes it, but it is not joinable/startable/updatable/invitable (all use `.joinable` = chartered). Any client that created one would strand the user. Ref: `room.rb:18,20`; `rooms_controller.rb:59,114,285`. 3. **409 returns the room body.** Server rejoin returns full room JSON on 409 (`rooms_controller.rb:181`); iOS silent-rejoin depends on it. The kit `joinRoom` (returns RoomDto) must parse the 409 body, not just the 200. 4. **Default threshold 100 fails on first give-up.** With `success_rate_threshold` default 100, one `failed_at` among N → `100*(N-1)/N < 100` → failure. iOS display approximation "tolerates one failure at 100" (iOS doc §2 edge note) is a **client approximation** the server does not honor — server integer math is strict `>=`. Ref: `room.rb:212`; RSpec `room_spec.rb` threshold-60 cases. 5. **Chop is authoritative & idempotent server-side.** Client-side `currentSuccessRate`/`maxNumberFailedParticipantsAllowed` are display-only; the server's `judge_success` decides `is_success` and the single `chopper_user_id` (first threshold-crosser). Answers iOS open question §11.1. 6. **No capacity gate on invite; cap 1000 only at join.** A host can over-invite; excess invitees hit 423 at join time. Consistent between layers (neither caps invites) but worth stating. 7. **`start_time` etc. are not client-settable** on create/update (`permit` lists exclude them) — RSpec `rooms_controller_spec.rb:226-239` confirms `start_time` param is ignored on update. 8. **Update tree_type rules** (`rooms_controller.rb:70-98`): changing to a *different* `owners_only_in_rooms?` tree → 423; same tree that is owners-only → allowed. RSpec `:267-296` covers all three sub-cases. --- ## 16. Appendix — constants & thresholds | Constant | Value | Source | Overridable | |---|---|---|---| | `Room::PARTICIPANT_LIMIT` | 1000 | `room.rb:23` | Constant `ROOM_PARTICIPANT_LIMIT` | | `Room::MAX_PARTICIPANT_LIMIT_FOR_PUSHING_UPDATE_NOTIFICATION` | 50 | `room.rb:24` | no | | `Room::MAX_SUCCESS_RATE_THRESHOLD` | 100 | `room.rb:8` | no | | `target_duration` range | 600..10800 s | `room.rb:5` | no | | `success_rate_threshold` range | 0..100 | `room.rb:11` | no | | notify throttle | 5 s | `room.rb:228` | no | | `ShuttleBus MAX_SEAT_COUNT` | 50 | `shuttle_bus_room_service.rb:4` | no | | `BUS_DURATION` | 10 min | `:9` | Constant `forest_2018_earthday_bus_duration` | | `BUS_INTERVAL` | 4h prod / 15m | `:5` | Constant `forest_2018_earthday_bus_period` | | `DOORS_OPEN_INTERVAL` | 5 min | `:6` | Constant `..._boarding_to_departure_duration` | | `WAIT_FOR_START_INTERVAL` | 30 s | `:7` | no | | `BUS_SUCCESS_RATE_THRESHOLD` | 80 | `:12` | Constant `forest_2018_earthday_bus_success_rate` | | `DRIVER_USER_ID` | 1449808 prod / 1 | `:8` | no | | joint-activity host | 719620 CN / 16883055 global | `room.rb:68-72` | SecretConstant window + email gate | ### Status codes used by the feature `200` ok · `201` created · `400` bad params · `401` wrong role / not participant / internal-no-token · `403` not signed in / internal-secret-mismatch · `404` room not found / not available · `409` already participant (+body) · `410` kick target gone / earthday window / boarding closed · `422` validation / add fail · `423` locked: tree not owned/missing, room full, too-few-to-start, cannot-kick-host · `483` tree owners-only not owned · `498` wrong region · `501` internal secret unset. --- ## 17. Background jobs / schedulers summary | Job | Trigger | Queue | Action | |---|---|---|---| | `TogetherNotificationWorker` | `TogetherNotificationService#send` | `default` | POST batch push to postoffice; resets `notification_scheduled_at` when broadcast | | `ScheduleShuttleBusWorker` | `room.schedule_shuttle_bus` after_commit (shuttle_bus) at `scheduled_start_time - 2s` | `low` | auto-`start` the bus room | | `ShuttleBusUsherWorker` | `POST /shuttle_bus_queues` | `critical` | matchmake via `new_request_ride_for`, notify `SHUTTLE_BUS_QUEUE_PROCESSED` | | `ScheduleShuttleBusJob` (dormant) | not wired to `perform_at` | `default` | same as worker (ActiveJob variant) | | `TogetherNotificationJob` (dormant) | commented in service | `default` | per-user push, no room_type | --- --- ## 18. Cross-validation against the iOS companion spec Every server↔client contract point was checked against the iOS doc (`2026-06-30-planttogether-reverse-engineered-spec.md`, all 1205 lines). Result: **endpoints and status-code maps agree exactly; the divergences are all cases where the client *assumes* a server behavior the server does not implement.** ### 18.1 Endpoint & verb agreement (✅ all match) | Server (this doc §1.5) | iOS reference | Match | |---|---|---| | POST /rooms | iOS §2 `TogetherDataManager.m:76-82` | ✅ | | PUT /rooms/participate `{token}` | iOS §3 `TogetherDataManager.swift:331-333` | ✅ | | PUT /:id/invite `{user_id}` | iOS §4 `TogetherDataManager.m:424-439` | ✅ | | PUT /:id/reject | iOS §5 `TogetherDataManager.m:442-444` | ✅ (iOS uses legacy `/api/v1/rooms/%@/reject`) | | PUT /:id/start | iOS §12.1 `TogetherDataManager.m:483-536` | ✅ | | PUT /:id/leave | iOS §12.2 `TogetherDataManager.m:588-620` | ✅ | | PUT /:id/kick `{user_id}` | iOS §12.3 `TogetherDataManager.m:459-481` | ✅ | | PUT /:id (update) | iOS §12.4 `TogetherDataManager.m:130-159` | ✅ | | PUT /:id/chop `?end_time=` | iOS §8 `TogetherDataManager.m:538-547` | ✅ (server reads `params[:end_time]` — query or body) | | GET /:id `?detail=` (poll) | iOS §7 `TogetherDataManager.m:189` | ✅ | | GET /rooms `?from_date=` (read-model) | iOS §10 `DataSyncManager.swift:475-513` | ✅ | ### 18.2 Status-code → iOS error map (✅ exact) Join (§3) and Start (§7) maps are 1:1 with iOS (already tabulated there). No client-visible status code is unhandled by iOS, and no iOS-expected code is unproduced by the server, with these refinements: - **Kick** — server returns extra codes iOS never exercises: **423** (kick-host) and **400** (no user_id). iOS only models 200/410 (both success). Not a conflict — the client UI never lets a host kick the host, so 423 is unreachable from iOS. Server is stricter than the client assumes. - **Leave** — server can return **401** (caller not a participant); iOS models only 200/404/403 (a leaving user is always a participant). Unreachable from iOS; no conflict. - **Update** — server distinguishes **404 / 401 / 423 / 422**; iOS collapses *all* update failures into one `UpdateRoomConfigurationFailed` → UI rollback + `fail_message_together_unknown` (iOS §12.4). Client loses the server's reason but the outcome (revert) is safe. ### 18.3 Response-body contract (✅ corroborated — explains client code) Server body shape per endpoint (§6 / controller): | Endpoint | 200/201 body | Participants included? | |---|---|---| | create (201) | room + `token` | **No** | | start (200) | room + `token` | **No** | | update (200) | `''` (empty) | — | | invite/reject/leave/kick/chop (200) | `''` (empty) | — | | participate (200 / 409) | room | **Yes** (`methods: :participants`) | | show (200) | room (+participants iff `detail`) | conditional | | index (200) | rooms | **Yes** | This directly explains two iOS behaviors: (a) iOS **synthesizes the host participant locally** after create (`TogetherDataManager.m:91-95`) precisely because the **create 201 body carries no participants array** (`rooms_controller.rb:52`, `room.as_json`); (b) iOS update success needs no body (`render json: '', status: 200`, `:101`). ✅ ### 18.4 Confirmed client↔server mismatches (client assumes behavior the server does NOT guarantee) 1. **Host leave ≠ room destroy** *(highest impact)*. iOS §7/§12.2 model host "Cancel" as **destroying** the room (transition line 636/673: "host leave (PUT /leave 200) → room destroyed"). Server merely removes the host participant → room becomes **Orphaned** (fails `available`) but **persists in DB forever**; no job deletes it. Guests still polling `GET /:id` keep getting 200 (they are still participants) and detect the loss only via **"no host in participants" → CannotSeeRoom** (iOS §7 `TogetherDataManager.m:306-325`). So the *effect* (guests get kicked out) is reached by a different mechanism than the client's mental model. Refs: server `rooms_controller.rb:229-246`, `room.rb:293-314`. 2. **No server-side timeout / auto-fail for chartered rooms.** iOS §8 edge (line 795) speculates that when a give-up is lost offline, "the group session … is decided entirely by **server-side timeout logic** (outside iOS)." **No such logic exists in this repo.** `is_success` defaults **true** (`db/schema.rb` rooms) and changes *only* inside `room.chop → judge_success` (`room.rb:327-349,211-214`). There is **no worker** that ends or fails a chartered room when `end_time` passes — `ongoing`/`ended` are query-time scopes only (`room.rb:133-139`). A room where nobody ever chops stays `is_success=true` indefinitely. (The only auto-lifecycle worker is `ScheduleShuttleBusWorker`, which **starts** bus rooms — it never chops/fails anything.) **The client assumption is false; a lost chop is simply never reflected server-side.** 3. **`birthday_2019_random` is inert server-side.** iOS §1.2/§5/§7 carry invite-display, banner (`birthday_2019_cake_box_title`) and join handling for birthday rooms. But the server's join (`find_available_joinable_with_token` → `available.joinable`) and invite (`available.joinable`) are restricted to `JOINABLE_TYPES = ['chartered']` (`room.rb:18,124-127`). A birthday-room token → **404**; invite to a birthday room → **404**. So the current server can populate a birthday room **only with its host** (`after_create`) — there is no live path to add guests. The client's birthday flows are **dead against this server**. Strengthens §15.2. 4. **`is_success` tolerance: iOS display over-counts by one.** iOS `maxNumberFailedParticipantsAllowed = N*(100-threshold)/100 + 1` (iOS §7 line 705, §8 line 781) is **display-only** — iOS itself states "the actual verdict is `_isSuccess` parsed from server." Server truth: `is_success = (100*alive/size >= threshold)` with **integer** division (`room.rb:212`). At default threshold 100 the server fails on the **first** give-up; the iOS formula would display "1 failure allowed." Confirmed by RSpec `room_spec.rb` (threshold 60: fails at 2 failures of 3). The two agree that the server is authoritative; the client number is a loose UI estimate. Matches §15.4. 5. **483 is generic, not TinyTAN-specific.** iOS §9 treats 483 as ⟺ TinyTAN-not-owned and shows a TinyTAN CTA. Server 483 fires for **any** `tree_type.owners_only_in_rooms?` tree the user doesn't own (`rooms_controller.rb:185-189`). If a non-TinyTAN tree ever gets `owners_only_in_rooms=true`, the client would wrongly show TinyTAN upsell. Currently benign (only TinyTAN types carry the flag) but the coupling is a client assumption, not a server guarantee. (Note: server 483 is unchanged by TinyTAN retirement — the ownership check still fires; on the **client**, TinyTAN being retired means the CTA is now dead and 483 surfaces as the cannot-join error, per iOS §9.) 6. **`room_type` is ignored on update.** iOS sends `room_type:"chartered"` on reconfigure (iOS §12.4, `TogetherDataManager.m:138`). Server `room_update_params` permits **only** `target_duration, tree_type` (`rooms_controller.rb:357-359`) — `room_type` is dropped (the `room_type_check` before_action only *validates* it, `:361-366`). Harmless (room stays chartered) but the client field has no effect. ### 18.5 Two-layer behaviors that agree (✅) - **Notification payload** — server `TogetherNotificationWorker` custom fields `{type, room_id, room_token, user_id, name, avatar, target_duration, tree_type, room_type}` (`together_notification_worker.rb:24-34`) map 1:1 onto iOS `TogetherInvitationNotificationPayload` parsing (iOS §4/§5). ✅ - **Update throttling is double-gated** — server throttles UPDATE pushes to ≥5 s via `notification_scheduled_at` (`room.rb:228`) **and** skips entirely when `participants.size > 50` (`room.rb:218`); iOS *also* throttles inbound push-updates client-side (`minIntervalForTogetherModeNotificationTriggedUpdates`, iOS §7 line 682) and relies on polling regardless. For rooms >50 the client gets **no** update pushes and depends purely on its poll timer — consistent with the iOS poll-based design (iOS §7 line 708). ✅ - **Delta sync watermark** — iOS advances its high-water mark on `rooms.updated_at` (iOS §10). Server keeps `updated_at` fresh via `Participant after_save → room.touch` (`participant.rb:15`) and `Room after_update` (`room.rb:84-93`), so participant churn surfaces in the client's `from_date` poll. ✅ (Suppressed for migrated rooms — `participant.rb:10` — which the client never sees anyway.) - **shuttle_bus is client-passive** — iOS never calls `participate` for bus rooms; membership is established server-side by `ShuttleBusUsherWorker → add_participant` (§10), and the client learns its `room_id` from the `SHUTTLE_BUS_QUEUE_PROCESSED` push, then polls `show`. Matches iOS §1.2 "shuttle_bus receive/join only." ✅ ### 18.6 Coverage asymmetry (documented in one spec, absent in the other — not conflicts) - **Server-only (no iOS coverage):** `POST/GET /shuttle_bus_queues` + `ShuttleBusUsherWorker` matchmaking (§10.1-10.4); `earthday_2018_*` reporting (§10.9); `GET /internal/rooms/:id` (§12); migrated-room notification/judge suppression (`intl_id`); the participant cap (default 1000, cloud-overridable — §10.8) and 50-push cap (§16). These are server/ops surfaces the client never touches. - **iOS-only (client-side, no server contract):** walkthrough onboarding (iOS §6); TinyTAN usage-limit UI/CTA/coach-marks (iOS §9 — server side is *only* the 483 code); deep-link `forest://join_room` + share sheet (iOS §12.5-12.6); relaunch restore / CoreData read-model / needsChopper retry queue (iOS §10); footer subscription/seasonal gating (iOS §12.7); notification-permission prompt (iOS §12.9); shuttle_bus header messaging/countdown (iOS §12.10). All are pure client concerns; the server exposes no matching endpoint or field. ### 18.7 Net assessment The two specs are **mutually consistent on the wire**: identical endpoints, identical status→error mapping, matching payloads and body shapes. The six items in §18.4 are the actionable outputs for the kit refactor — each is a place where the current client encodes an assumption (room destruction on host-leave, a server failure-timeout, birthday joinability, TinyTAN-specific 483, an update-failure reason, an effective `room_type` on update) that the **server does not actually provide**. None are wire breakages today; they are latent contract gaps to preserve-or-fix deliberately. --- *End of spec. All behavioral claims traced to `forest-server` source at the cited `file:line`; RSpec suites read and cross-referenced (not executed — local `pg` gem build failure). Corrections vs seed facts in §14, client↔server mismatches in §15, and a full cross-validation against the iOS companion in §18.*