The canonical schema lives in `checkin_cli/nutrition_onboarding_contract.py`. Let me read it plus the runtime collection logic to answer the bulk-vs-single-message question.


`checkin_cli` is not in this repo. It's an external installed package. Let me locate it and find the exact schema plus the skip/optional-default and submit flow.


The canonical `checkin_cli` contract isn't in the working tree as source (it's a shipped package), but exact copies exist in the evidence snapshots. Let me read the contract there plus the service-side submit/domain logic in `nutrition_coaching.py`.


The v3 path exists (ls succeeded). Let me read the contract file directly.


I have the canonical field order and Korean copy. Now I need the validation ranges/constraints and conditional branching from the domain logic. Let me dig into the checkin_cli package.


Now let me confirm the per-question transitions, the safety-hold branching, and how messages/prompts are published.


Let me confirm the safety-hold branching trigger.


Let me pin exact line numbers for citations and confirm the parse/unions precisely.


<analysis>
**Literal Request**: Inspect the exact production onboarding schema for the dualcoach Telegram customer flow (currently at `date_of_birth`, question 1/22) and return, in runtime order, each question key, prompt summary, input type, accepted format/choices/ranges, conditional branching rules, one consistent synthetic adult answer per field, plus whether replies must be one-message-at-a-time or bulk.

**Actual Need**: A caller who must drive this live flow through the Telegram UI step by step (e.g. an automated/demo customer) needs an authoritative, line-cited questionnaire so they can answer all 22 fields correctly and pass validation in one pass.

**Success Looks Like**: A complete, ordered, internally consistent 22-field answer sheet with exact Korean prompt, allowed formats/ranges/choices, every condition that changes later questions or state, and the one-per-message rule confirmed by code — all with source paths+lines.
</analysis>

The production schema is a split design: the ordered 22-field contract and value-models live in the **shipped `checkin_cli` package** (not present as source in this repo; an exact copy is snapshot under `.omo/evidence/.../snapshot/profile-package/checkin_cli/`), while the Telegram display copy and per-answer parsing live in `gateway/platforms/telegram_nutrition_onboarding_copy.py`. The runtime that drives 1/22 is `gateway/platforms/telegram_nutrition_onboarding_runtime_collection.py`.

<results>
<files>
- /home/cube/projects/richard/hermes-agent/.omo/evidence/task26/task26-owner-customer-v1-candidate-v3/snapshot/profile-package/checkin_cli/nutrition_onboarding_contract.py - Canonical `QUESTION_FIELDS` ordered tuple (22 fields, lines 13-38) and `example_answer` (lines 79-101). Imported at runtime as `checkin_cli.nutrition_onboarding_contract` (e.g. gateway/platforms/telegram_nutrition_onboarding_preview_flow.py:6).
- /home/cube/projects/richard/hermes-agent/.omo/evidence/task26/task26-owner-customer-v1-candidate-v3/snapshot/profile-package/checkin_cli/nutrition_onboarding_models.py - Value model: adult_age/height/weight/target/meal_count ranges (lines 127-147), `validate_goal` goal-target branching (174-180), `derive_adult_age` 18-120 enforcement (189-195), free-text 500-char cap (163-...).
- /home/cube/projects/richard/hermes-agent/.omo/evidence/task26/task26-owner-customer-v1-candidate-v3/snapshot/profile-package/checkin_cli/nutrition_onboarding_authority.py - `requires_safety_hold` trigger (lines 83-88).
- /home/cube/projects/richard/hermes-agent/.omo/evidence/task26/task26-owner-customer-v1-candidate-v3/snapshot/profile-package/checkin_cli/nutrition_onboarding.py - `submit_answer` enforces one-field-at-a-time (cursor must equal current `QUESTION_FIELDS[cursor]`, lines ~157-196); `attest_baseline` builds baseline and routes to SAFETY_HOLD vs OWNER_REVIEW (~250-281).
- /home/cube/projects/richard/hermes-agent/.omo/evidence/task26/task26-owner-customer-v1-candidate-v3/snapshot/profile-package/checkin_cli/nutrition_onboarding_session.py - `build_status` derives `next_field=QUESTION_FIELDS[cursor]` (line 37).
- /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_nutrition_onboarding_copy.py - Korean `PROMPTS` dict (lines 49-71), `card()` display routing (73-96), `parse_answer` per-field parsing (99-149).
- /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_nutrition_onboarding.py - `OPTIONAL_QUESTION_DEFAULTS` skip set (lines 31-35).
- /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_nutrition_onboarding_runtime_collection.py - `handle_text` one-answer-per-message ingestion (line 53), skip handling (237-243).
- /home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_nutrition_onboarding_runtime_publication.py - `[n/22]` header and forced-reply question prompt (lines ~58-67).
</files>

<answer>
The flow displays each question as a forced-reply card "`[n/22] <Korean prompt>`" and advances by stored cursor. Answers are validated for format at parse time and for range at final baseline build. Ranges not enforced at the parse layer are enforced when `build_onboarding_baseline` runs (after attestation), so a value passing parse can still hard-fail finalization.

**One-message-at-a-time — YES, bulk/multiline is NOT supported.** `handle_text` (runtime_collection.py:53) ingests a single Telegram message, parses exactly the current `status.next_field` (one field), and calls `submit_answer` for that one field; `submit_answer` rejects any message whose field isn't the current cursor question (nutrition_onboarding.py:185-195). Nothing splits answers across multiple questions. "Multiline" only exists *inside* a single field: comma-separated items for the 7 structured list fields (allergies, etc., parsed in telegram_nutrition_onboarding_copy.py:100-107). Each of the 22 questions must be answered in its own message before the next card is published.

**Skip escape (`건너뛰기`)**: only for 4 optional free-text fields (OPTIONAL_QUESTION_DEFAULTS, telegram_nutrition_onboarding.py:31-35; enforced at runtime_collection.py:237-243). These map to "". The prompt footer advertises skip only for these (runtime_publication.py:65-67).

**The 22 questions in exact runtime order** (CONTRACT: nutrition_onboarding_contract.py:13-38; PROMPTS Korean copy at telegram_nutrition_onboarding_copy.py:49-71; parse at :99-149; range/model facts from nutrition_onboarding_models.py):

1. `date_of_birth` — "생년월일을 YYYY-MM-DD 형식으로 입력해 주세요." Input: ISO date string `YYYY-MM-DD`. Format enforced at final build via `date.fromisoformat` + `derive_adult_age`: derived age must be 18-120 as-of build date (models.py:189-195; baseline `adult_age` ge=18 le=120 at :127). Parse returns raw string (no parse-time check). Answer: `1996-08-01` (age 30).

2. `equation_sex_basis` — "계산 기준을 입력해 주세요: 남성 / 여성 / 선택 안 함". Choices: 남성→`male`, 여성→`female`, 선택 안 함→`decline` (enum EquationSexBasis; parse mapping copy.py:111-114; enum models.py validates at :95). Answer: `남성`.

3. `height_cm` — "키를 cm 단위 숫자로 입력해 주세요." Numeric with unit: regex `\d+(\.\d+)?\s*cm` (parse copy.py:23-28 via `_parse_measurement`, unit table :6-8). Range 120-250 cm (models.py:129). Answer: `180cm`.

4. `weight_kg` — "현재 체중을 kg 단위 숫자로 입력해 주세요." Numeric `\d+(\.\d+)?\s*kg`. Range 35-300 kg (models.py:130). Answer: `80kg`.

5. `activity_category` — "활동 수준: 좌식 / 가벼움 / 보통 / 매우 활동적 / 최고 활동". Choices: 좌식→`sedentary`, 가벼움→`light`, 보통→`moderate`, 매우 활동적→`very_active`, 최고 활동→`extra_active` (parse mapping copy.py:116-119; enum models.py:99-105). Answer: `보통`.

6. `activity_rationale` — "주간 운동 횟수와 활동 근거를 적어 주세요." Free text, **optional/skippable** (건너뛰기 → ""). ≤500 chars at build (models.py:163-182). Answer: `주 3회 근력 운동`.

7. `goal_type` — "목표를 입력해 주세요: 감량 / 유지 / 증량". Choices: 감량→`loss`, 유지→`maintain`, 증량→`gain` (copy.py:120-122; enum models.py:107-113). **Primary branch**: if `maintain`, target_weight_kg AND target_date must both be absent (`None`); if `loss`/`gain`, both are REQUIRED (validate_goal, models.py:174-180). Answer: `유지` (maintain).

8. `target_weight_kg` — "목표 체중을 kg으로 입력해 주세요. 유지라면 '없음'." Branch-dependent: `없음`/`none` → `None` (copy.py:133-134); else kg measure, range 35-300 (models.py:134). For a maintain goal must be None. Answer: `없음`.

9. `target_date` — "목표 날짜를 YYYY-MM-DD로 입력해 주세요. 유지라면 '없음'." Branch-dependent: `없음`/`none` → `None` (copy.py:133-134); else ISO date. For maintain goal must be None. Answer: `없음`.

10. `allergies` — "알레르기를 쉼표로 구분해 입력해 주세요. 없으면 '없음'." Structured: `없음`/`none` → `{"status":"none","items":[]}`; else comma-split → `{"status":"provided","items":[...]}` (copy.py:100-107). Items ≤200 chars, ≤40 items (models.py StructuredItems :55-75). Not safety-triggering. Answer: `없음`.

11. `intolerances` — "불내증을 쉼표로 구분해 입력해 주세요. 없으면 '없음'." Same structured format as #10. Answer: `없음`.

12. `religious_ethical_exclusions` — "종교·윤리상 제외 식품을 입력해 주세요. 없으면 '없음'." Same structured format. Answer: `없음`.

13. `disliked_foods` — "싫어하는 식품을 입력해 주세요. 없으면 '없음'." Same structured format. Answer: `없음`.

14. `dietary_preferences` — "선호 식품을 입력해 주세요. 없으면 '없음'." Same structured format. Answer: `없음`.

15. `conditions` — "의학적 고려사항을 입력해 주세요. 없으면 '없음'." Structured format, **branching**: `status:provided` (any item) forces SAFETY_HOLD at attestation (authority.py:88). Answer: `없음` (none, avoids hold).

16. `medications` — "복용 약·보충제를 입력해 주세요. 없으면 '없음'." Structured format, **branching**: `status:provided` also forces SAFETY_HOLD (authority.py:88). Answer: `없음`.

17. `pregnancy_breastfeeding` — "임신·수유 중인가요? 예 / 아니오 / 확실하지 않음". Choices: 예→`True`, 아니오→`False`, 확실하지 않음→`None` (copy.py:126-127). **Branching**: any non-`False` value (True or unsure/None) forces SAFETY_HOLD (authority.py:87). Answer: `아니오`.

18. `eating_disorder_risk` — "섭식장애 위험·과거력이 있나요? 예 / 아니오 / 확실하지 않음". Choices: 예→`True`, 아니오→`False`, 확실하지 않음→`None`. **Branching**: non-`False` forces SAFETY_HOLD (authority.py:88). Answer: `아니오`.

19. `cooking_access` — "사용 가능한 조리 환경을 적어 주세요." Free text, **optional/skippable** (→ ""). ≤500 chars. Answer: `기본 조리 가능`.

20. `budget_band` — "식비 범위를 적어 주세요." Free text, **optional/skippable** (→ ""). ≤500 chars. Answer: `보통`.

21. `meal_count` — "하루 식사 횟수를 2~6 숫자로 입력해 주세요." Numeric integer; parse accepts plain int, `하루 N끼/회`, or Korean numerals 한...열 (copy.py:31-40). Range enforced 2-6 at build (models.py:147). Answer: `3`.

22. `schedule_constraints` — "식사 시간 제약을 적어 주세요. 없으면 '없음'." Free text, **optional/skippable** (→ ""). ≤500 chars. Answer: `없음`.

**Post-22-questions state machine (conditional, not per-question)**: after field 22, state becomes `customer_attestation` (nutrition_onboarding.py:190-193); customer attests → `build_onboarding_baseline` runs (all ranges/age/format enforced, models.py + build fn line 211) → state routes to `SAFETY_HOLD` if ANY of conditions/medications provided, or pregnancy/ED not False (authority.py:83-88), else `OWNER_REVIEW` (nutrition_onboarding.py:274-279). Safety hold requires external clinical clearance to reach owner review; owner may approve (→ finalizing → ready), revise (re-opens at a chosen field, keeping earlier answers), reject, or hold (nutrition_onboarding.py:295-357).

The internally consistent synthetic adult run above: a 30-year-old maintain-goal male, no allergies/intolerances/conditions/medications, not pregnant, no ED risk — passes all build checks and routes to OWNER_REVIEW (not SAFETY_HOLD) so the flow completes without clinical intervention.
</answer>

<next_steps>
Drive the flow by replying to each forced card (one message per question) using the 22 answers above in order; reply `유지` at Q7 and `없음` for Q8/Q9 to keep the maintain-goal consistent. For a loss/gain goal instead, Q8/Q9 must be real values (35-300 kg and a YYYY-MM-DD date). To make the run reach `ready` rather than owner review, keep Q15/Q16 as `없음` and Q17/Q18 as `아니오`. No follow-up needed — the schema, branching, and answer sheet are complete and all facts are line-cited.
</next_steps>
</results>