I read this as an advisory architecture task - I’ll inspect the existing Telegram and `nutrition_daily` flows, then specify implementation-ready state contracts, UX rules, edge cases, and acceptance criteria without editing files.


message: WebSocket error

stop_reason: error


I read this as an advisory architecture task - I’ll provide an implementation-ready option-2 contract for `nutrition_daily`, grounded in the existing wizard and Telegram behavior, without modifying files.

## Bottom line

Use the existing durable wizard as authority, but redefine Telegram progress as **completed answers**, add first-class `unknown` and non-destructive previous-answer editing, and separate domain mutation from Telegram publication outcomes. This is a **Medium** change; no new outbox or web surface is warranted, but publication ambiguity and the currently active calories-step session require explicit compatibility handling.

## Action plan

### 1. Adopt this state model

Keep correction status orthogonal to phase:

```python
class NutritionPhase(StrEnum):
    COLLECTING = "collecting"
    EDITING_PREVIOUS = "editing_previous"
    REVIEW = "review"
    EDITING_REVIEW = "editing_review"
    DEFERRED = "deferred"
    SAFETY_ACK = "safety_ack"
    FINALIZED = "finalized"

class RecordMode(StrEnum):
    ORIGINAL = "original"
    CORRECTION = "correction"
```

Additive session fields:

```python
phase: NutritionPhase = COLLECTING
record_mode: RecordMode = ORIGINAL
unknown_steps: tuple[str, ...] = ()
return_step: str | None = None
deferred_phase: NutritionPhase | None = None
dirty_steps: tuple[str, ...] = ()
last_ingress_id: str | None = None
```

Retain existing `answers`, `step_history`, `supersedes`, `macro_order`, session identity, and version fields.

#### Phase transitions

| Current phase | Action | Next phase | Invariant |
|---|---|---|---|
| none | start | `COLLECTING` | Create one base session per exact customer/owner/topic/KST day |
| open phase | start/resume | unchanged or restored | Same session ID; never create a parallel draft |
| `COLLECTING` | known answer | `COLLECTING` or `REVIEW` | Commit answer and move to first unanswered active step |
| `COLLECTING` | unknown | `COLLECTING` or `REVIEW` | Mark field unknown; do not store a magic string |
| `COLLECTING` | previous | `EDITING_PREVIOUS` | Preserve every existing answer; set `return_step` |
| `EDITING_PREVIOUS` | replacement | `COLLECTING` or `REVIEW` | Replace only target answer; retain later base answers |
| `EDITING_PREVIOUS` | cancel | `COLLECTING` | Old answer remains authoritative |
| `REVIEW` | choose field | `EDITING_REVIEW` | Preserve all answers |
| `EDITING_REVIEW` | replacement | `REVIEW` or `COLLECTING` | Return to review unless new adaptive questions became necessary |
| mutable phase | defer | `DEFERRED` | Preserve phase, step, answers, unknowns, and correction metadata |
| `DEFERRED` | explicit resume | saved phase | Resume exact question or edit screen |
| any input phase | safety match | `SAFETY_ACK` | No normal check-in event may be finalized |
| `SAFETY_ACK` | acknowledge | `FINALIZED` | Append only a non-trend safety audit |
| `REVIEW` | save | `FINALIZED` | Append exactly one nutrition/correction event |
| `FINALIZED` | any mutation | unchanged | Return stale/finalized; never append again |

### 2. Enforce these state-machine invariants

1. **Authority:** the private session file is authoritative; Telegram message state is only a projection.
2. **Identity:** every mutation requires exact customer, owner, chat/topic, session ID, current version, and current KST-day eligibility.
3. **Versioning:** every accepted state mutation increments the version exactly once. Invalid, stale, replayed, expired, and unauthorized inputs do not.
4. **Final-save-only:** known answers, unknown answers, edits, defer, and review create no canonical event.
5. **Unknown representation:** a step is exactly one of:
   - known: key exists in `answers`;
   - unknown: key exists in `unknown_steps`;
   - unanswered: neither.
6. A step cannot be both known and unknown.
7. `optional_note` uses known empty/“없음”; it does not offer unknown.
8. All other 11 base questions and adaptive follow-ups offer `잘 모르겠어요`.
9. Unknown numeric fields become `None` in `ContractCheckin`; never `"unknown"`, zero, or an inferred value.
10. Unknown macros omit all three macro fields.
11. A partial final event is valid, but remains ineligible for the existing trend view unless bodyweight and calories are both known.
12. **Previous means edit, not rewind:** later base answers are retained. Only adaptive answers whose triggering condition is no longer active are removed.
13. Entering edit mode does not discard the old answer. Restart or edit cancellation therefore cannot lose data.
14. Correction drafts preload the latest finalized snapshot and always supersede the latest correction, not the original event.
15. A correction draft does not change the current canonical record before save.
16. Safety classification runs before ordinary parsing and before unknown conversion.
17. Finalized sessions are immutable even when Telegram confirmation publication fails.
18. Exact Telegram update replay is deduplicated by `last_ingress_id` plus the existing polling receipt gate. Text equality is never a dedupe key.

### 3. Use typed outcomes rather than free-form status messages

```python
class NutritionOutcomeKind(StrEnum):
    STARTED = "started"
    RESUMED = "resumed"
    ANSWER_RECORDED = "answer_recorded"
    UNKNOWN_RECORDED = "unknown_recorded"
    EDIT_OPENED = "edit_opened"
    EDIT_RECORDED = "edit_recorded"
    EDIT_CANCELLED = "edit_cancelled"
    REVIEW_READY = "review_ready"
    DEFERRED = "deferred"
    SAFETY_HELD = "safety_held"
    SAVED = "saved"
    SAFETY_AUDITED = "safety_audited"
    ALREADY_COMPLETED = "already_completed"
    REPLAYED = "replayed"
    INVALID = "invalid"
    STALE = "stale"
    UNAVAILABLE = "unavailable"
    EXPIRED = "expired"

@dataclass(frozen=True, slots=True)
class NutritionProgress:
    completed: int
    total: int

@dataclass(frozen=True, slots=True)
class NutritionOutcome:
    kind: NutritionOutcomeKind
    session_id: str
    from_version: int | None
    version: int
    phase: NutritionPhase
    current_step: str | None
    next_step: str | None
    progress: NutritionProgress
    record_mode: RecordMode
    can_previous: bool
    can_unknown: bool
    can_defer: bool
    copy_key: str
    reason_code: str | None = None
```

This outcome must never contain the submitted or previous value. Unknown, unauthorized, and missing-session cases use bounded reason codes rather than exception text.

### 4. Define progress mathematically

Base plan:

```text
bodyweight
calories
macros
meals
water
sleep_duration
sleep_quality
digestion
condition
appetite_stress
training_summary
optional_note
```

Adaptive follow-ups are inserted immediately after their triggering question.

```text
active_plan = 12 base steps + currently active adaptive follow-ups
total = len(active_plan)
completed = count(
    step in active_plan
    where step in answers or step in unknown_steps
)
```

Rules:

- Initial screen: `진행 0/12`.
- After bodyweight: `진행 1/12`, then calories.
- Summary: `진행 total/total`.
- Unknown counts as completed.
- Optional-note “없음” counts as completed.
- Entering previous-edit mode does not reduce completed count because the old answer remains valid until replaced.
- Adaptive questions may increase or decrease the denominator after a triggering answer is entered or edited.
- Removed adaptive questions and their answers no longer count.
- Do not call the current question ordinal “progress.” Internally it may be `index + 1`, but customer copy always reports completed answers.

### 5. Lock the Telegram interaction copy

#### Standard templates

Start:

```text
진행 {completed}/{total}

첫 질문
{question}
```

Accepted known answer:

```text
답변을 기록했습니다.
진행 {completed}/{total}

다음 질문
{question}
```

Accepted unknown:

```text
‘잘 모르겠어요’로 기록했습니다.
진행 {completed}/{total}

다음 질문
{question}
```

Resume:

```text
체크인을 이어갑니다.
진행 {completed}/{total}

다음 질문
{question}
```

Previous-answer editing:

```text
이전 답변을 수정합니다.
진행 {completed}/{total}

{question}
```

Invalid answer:

```text
입력 형식을 확인해 주세요. 현재 답변은 기록하지 않았습니다.
진행 {completed}/{total}

현재 질문
{question}
```

Defer:

```text
임시 저장했습니다.
오늘 안에 고객 메뉴에서 ‘오늘 체크인 이어서 하기’를 누르면 현재 질문부터 계속할 수 있습니다.
```

Value-free review:

```text
입력을 모두 마쳤습니다.
진행 {total}/{total}

입력 상태
- 입력함: {known_count}개
- 잘 모름: {unknown_count}개
- 없음: {empty_count}개

답변 내용은 이 화면에 다시 표시하지 않습니다.
```

Completion:

```text
오늘 체크인을 저장했습니다.
입력한 값은 이 대화에 다시 표시하지 않습니다.
```

Correction review:

```text
오늘 체크인 수정
기존 기록은 그대로 보존됩니다. 저장하면 수정 기록이 추가됩니다.
답변 내용은 이 화면에 다시 표시하지 않습니다.
```

#### Buttons

- Typed and bounded questions: `잘 모르겠어요`.
- Navigation: `이전 답변 수정`, `나중에 계속`.
- Edit mode: `수정 취소`, `나중에 계속`.
- Optional note: `없음`, `직접 입력`; no unknown button.
- Summary: `체크인 저장`, `입력 수정`, `나중에 계속`.
- Correction summary: `수정 기록 저장`, `입력 수정`, `나중에 계속`.

Callback actions remain opaque:

```text
u = unknown
p = previous-answer edit
c = cancel edit
l = defer
```

Keep the existing `pc1:<session>:<step>:<version>:<action>` grammar and 64-byte limit.

#### No-sensitive-echo constraint

The stepper must never place raw or normalized answers in:

- bot messages, edited messages, callback toasts, keyboards, or summaries;
- callback data;
- reply quotes or `reply_to` previews;
- logs, exception text, bindings, delivery receipts, or reason codes.

Static field names and status counts are allowed. The user’s original Telegram message is not echoed or quoted.

### 6. Separate domain outcomes from Telegram publication outcomes

```python
class PublicationKind(StrEnum):
    DELIVERED = "delivered"
    NOT_SENT = "not_sent"
    UNKNOWN = "unknown"

@dataclass(frozen=True, slots=True)
class PromptPublication:
    kind: PublicationKind
    message_id: str | None
    reason_code: str | None
```

Publication invariants:

1. Commit the domain transition before rendering its confirmation/next question.
2. For typed answers, send one fresh message after the user’s message; do not reply-quote it.
3. For callback answers, edit the current card when possible.
4. A deterministic edit failure such as “message not found/cannot be edited” permits one fresh-send fallback.
5. A connect or pool timeout proven to occur before a request is `NOT_SENT`; at most one safe retry is allowed.
6. Read/write/general timeouts, connection loss after request start, and unclassified transport exceptions are `UNKNOWN`.
7. `UNKNOWN` must never trigger a fallback or automatic retry.
8. On `UNKNOWN`, preserve the committed domain step/version and mark the binding presentation unknown. Explicit user Start/Resume is the recovery mechanism.
9. Restart must never retry a pending or unknown publication automatically.
10. A final-save publication failure cannot roll back or duplicate the canonical event.
11. “Message is not modified” is successful delivery, not failure.

Extend bindings additively with:

```python
presentation_state: Literal["delivered", "pending", "unknown"]
presented_step: str | None
presented_version: int | None
```

If the domain version is newer than the persisted binding on restart, reconcile the binding to the domain and mark presentation `unknown`; do not send automatically.

### 7. Preserve legacy and the active calories-step session

Use lazy additive migration rather than bulk rewriting all private drafts.

Legacy defaults:

- missing `phase` -> derive `COLLECTING`, `REVIEW`, `SAFETY_ACK`, or `FINALIZED` from existing fields;
- missing `unknown_steps` -> empty;
- missing edit/defer fields -> `None`;
- missing `record_mode` -> correction when `supersedes` exists, otherwise original;
- missing `last_ingress_id` -> `None`;
- preserve `macro_order` exactly;
- derive absent history only when answers form a valid prefix; inconsistent drafts fail closed and remain unmodified.

The currently active production draft was verified to be at `calories`, version 1, with one completed prior field and a matching active Telegram binding. Migration acceptance for that session is exact:

- same session ID, customer boundary, KST day, version, and `calories` step;
- `completed=1`, `total=12`;
- `phase=COLLECTING`;
- no unknown or edit metadata introduced;
- same bound Telegram message and lease;
- no canonical event or replacement draft created;
- the first accepted calories answer moves version 1 to 2, progress to `2/12`, and next step to `macros`;
- replaying that Telegram update cannot move beyond `macros`.

Do not proactively edit the existing live Telegram calories prompt during deployment. New copy appears only after an explicit answer or resume.

### 8. Edge-case matrix

| Scenario | Domain result | Telegram behavior |
|---|---|---|
| Normal typed answer | One version advance | Generic confirmation plus next question; no value |
| Choice button | One version advance | Edit current card; stale old callback rejected |
| `잘 모르겠어요` | Unknown marker, progress advances | Unknown confirmation; no sentinel value |
| Invalid input | No mutation | Re-render current question without input echo |
| Previous-answer edit | Preserve all answers; enter edit mode | Show previous question without old answer |
| Edit changes adaptive branch | Recompute plan; remove obsolete adaptive answers | Show first newly unanswered question |
| Defer | Version advances; no event | Clear active presentation and show resume instructions |
| Resume | Same session and exact phase | Fresh current prompt |
| Process restart | Reload session/binding | No unsolicited send |
| Lease expiry | No mutation | Ask customer to reopen from menu |
| KST-day rollover | Old draft remains incomplete | New day starts a new base draft; old callbacks reject |
| Callback replay | `STALE` or `REPLAYED` | Toast only; no prompt publication |
| Same update replay after crash | `REPLAYED` via ingress ID | No second mutation or send |
| Same text in a new Telegram message | New input attempt | Validate against current step; never content-dedupe |
| Definitive edit failure | Domain remains advanced | One fresh-send fallback |
| Ambiguous edit/send | Domain remains advanced | No retry; explicit resume only |
| Crash after domain save, before publication | Advanced state survives | Restart marks presentation unknown |
| Unknown/foreign session | `UNAVAILABLE` | Generic non-oracular recovery copy |
| Urgent safety text | `SAFETY_HELD` | Static emergency copy; no excerpt echo |
| Non-urgent safety text | `SAFETY_HELD` | Static medical-review copy; no diagnosis |
| Save replay | Already finalized | No second event; completion/review notice only |
| Completed-record correction | Separate correction draft | Value-free correction review |
| Correction safety hold | Original remains current | Append safety audit only after acknowledgement |

Safety copy:

Urgent S1:

```text
안전을 위해 체크인 저장과 코칭을 보류했습니다.
흉통, 실신, 호흡 곤란 같은 증상이 있으면 즉시 119 또는 응급의료기관의 도움을 받아 주세요.
```

Other safety classes:

```text
안전 신호가 확인되어 체크인 저장과 코칭을 보류했습니다.
증상이 있거나 악화되면 의료진과 상담해 주세요.
```

Never interpolate the matched input excerpt.

### 9. Testable acceptance criteria

1. A 12-question known-answer walkthrough starts at `0/12`, reaches `12/12`, creates no event before save, and appends exactly one event after save.
2. Every post-answer prompt contains confirmation, completed-count progress, and the next question, but none of the submitted sentinel values.
3. Unknown on each supported step advances progress and is omitted from the typed event field.
4. Unknown bodyweight or calories produces a valid partial event but no trend-eligible check-in.
5. Previous-answer edit preserves all later base answers and returns to the prior continuation point.
6. Editing a trigger recomputes adaptive questions and removes only obsolete adaptive answers.
7. Defer/resume survives a new service and bridge instance with the same session, step, mode, and correction chain.
8. Restart after a committed transition never reapplies the same Telegram update.
9. Callback payloads remain at most 64 bytes and contain no submitted value.
10. Replayed callbacks and exact duplicate update IDs cause zero version/event/publication changes.
11. Two distinct Telegram messages with identical text are not content-deduplicated.
12. An ambiguous edit or send causes zero fallback sends; a proven-not-sent failure permits at most one.
13. Restart from publication `pending/unknown` sends nothing until explicit resume.
14. Expired and wrong-address interactions cause no mutation and expose no session existence.
15. Safety tests assert static urgent/non-urgent guidance, no diagnosis or prescription, no value echo, no normal event, and one safety audit only after acknowledgement.
16. Correction tests prove the original remains current until save and each saved correction supersedes the latest event exactly once.
17. Legacy fixtures without new fields load without rewrite or data loss.
18. The active calories-step fixture remains version 1 at calories and advances exactly once to macros.
19. Summary and completion Telegram calls contain no raw answer values.
20. No web UI, generic Telegram behavior, other wizard flows, or downstream coaching policy is changed.

## Risks and mitigations

- **Current code falls back to a fresh send after any edit exception.** Classify deterministic not-sent versus ambiguous outcomes; never use a generic `retryable` flag as proof that resend is safe.
- **Current summary and completion render sensitive values.** Centralize all `nutrition_daily` stepper copy behind value-free renderers and test with unique sentinel values.
- **Progress currently means current-question position.** Rename the contract to `completed/total`; update tests expecting `1/12` on the initial question.
- **Previous currently behaves like rewind and can discard later answers.** Introduce persistent edit mode and `return_step`; do not overload `step_history`.
- **Dynamic progress can decrease during an edit.** This is intentional because the active question plan changed; state this in tests rather than forcing misleading monotonic percentages.
- **Migration touches a live calories-step session.** Use additive defaults, snapshot metadata only, avoid deployment-time Telegram edits, and verify identity/version/step before and after loading.

Read-only verification covered the current wizard models, domain transitions, bindings, customer coordinator, Telegram rendering paths, receipt gate, event schema, trend eligibility, tests, and live session metadata. No files were edited and no tests were run.


I read this as a request to close the design on two remaining choices - I’ll select the smallest robust option-2 publication pattern and lock unknown semantics for every `nutrition_daily` field.

## Bottom line

**Decision 1:** publish exactly one new card containing the confirmation prefix, progress, and next question; do not edit the old card or send a second content card. **Decision 2:** store unknown as field metadata separate from `answers`, emit `None`/omitted canonical fields, and never convert unknown into zero, normal, adherence, or any inferred metric.

## Action plan

### 1. Decision: one new confirmation-plus-next-question card

After every accepted text or button answer, commit the domain transition and send one new card:

```text
✅ 답변을 기록했습니다.
진행 {completed}/{total}

다음 질문
{question}
```

Unknown:

```text
✅ ‘잘 모르겠어요’로 기록했습니다.
진행 {completed}/{total}

다음 질문
{question}
```

Edited answer:

```text
✅ 수정한 답변을 기록했습니다.
진행 {completed}/{total}

다음 질문
{question}
```

Final answer:

```text
✅ 답변을 기록했습니다.
진행 {total}/{total}

입력을 모두 마쳤습니다.
답변 내용은 이 화면에 다시 표시하지 않습니다.
```

#### Publication contract

1. The new card is the sole content-publication operation for an accepted transition.
2. Do not edit the old card, remove its keyboard, or send a separate confirmation.
3. For callbacks, `answerCallbackQuery("✓")` is best-effort acknowledgement only; it is not the confirmation UX.
4. The old card remains visible, but its callback version is stale immediately after the domain commit.
5. Reusing an old button returns a toast only and causes no new card or mutation.
6. Before sending, persist the new domain step/version and binding presentation state as `pending`.
7. On success, persist `delivered`, message ID, presented step, and presented version.
8. On any transport failure:
   - `not_sent`: preserve the transition; no automatic retry;
   - `unknown`: preserve the transition; no automatic retry;
   - explicit Start/Resume publishes the current question later.
9. Restart never republishes `pending` or `unknown` automatically.
10. Final-save publication failure cannot roll back or duplicate the canonical event.

This is smaller and safer than edit-old-plus-send-next: it removes a second ambiguous Telegram write, avoids ordering races between two messages, and gives identical chronology for typed and button answers.

### 2. Unknown storage contract

Add fields without changing the existing private `answers` representation:

```python
state_schema_version: int = 2
unknown_steps: tuple[str, ...] = ()
```

For every field `F`:

```text
Known(F)      := F in answers and F not in unknown_steps
Unknown(F)    := F not in answers and F in unknown_steps
Unanswered(F) := F not in answers and F not in unknown_steps
```

These states are mutually exclusive.

Transitions:

- Known answer: set `answers[F]`; remove `F` from `unknown_steps`.
- Unknown answer: remove `answers[F]`; add `F` to `unknown_steps`.
- Edit known to unknown: perform the unknown transition.
- Edit unknown to known: perform the known transition.
- Unknown counts as completed progress.
- Unknown callback action is opaque `u`; callback data contains no value.
- Never infer unknown from free text such as “모름” or “기억 안남”; only the explicit button/action creates unknown state.

### 3. Exact per-field event semantics

| Step | Unknown private state | Typed `ContractCheckin` | Canonical JSON |
|---|---|---|---|
| `bodyweight` | `unknown_steps += bodyweight` | `body_weight_kg=None` | field omitted |
| `calories` | `unknown_steps += calories` | `calories_kcal=None` | field omitted |
| `macros` | one unknown marker for the composite | `carbohydrate_g=None`, `protein_g=None`, `fat_g=None` | all three omitted |
| `meals` | `unknown_steps += meals` | `meal_summary=None` | field omitted |
| `water` | `unknown_steps += water` | `water_liters=None` | field omitted |
| `sleep_duration` | marker only | `sleep_hours=None` | field omitted |
| `sleep_quality` | marker only | `sleep_quality_1to5=None` | field omitted |
| `digestion` | marker only | `digestion_summary=None` | field omitted |
| `condition` | marker only | `readiness_1to5=None` | field omitted |
| `appetite_stress` | marker only | `appetite_stress_summary=None` | field omitted |
| `training_summary` | marker only | `training_summary=None` | field omitted |
| `optional_note` | unknown not offered | “없음” stores private empty string | `notes=None`, field omitted |

Additional rules:

- All fields except `optional_note` show `잘 모르겠어요`.
- `optional_note` retains `없음` and `직접 입력`.
- An all-unknown submission still creates one accepted `nutrition_checkin` event with an empty/partial `ContractCheckin`; it records that the check-in was completed without inventing measurements.
- The finalized private session retains `unknown_steps`; the canonical event represents unknown as missing fields rather than schema-expanding sentinel strings.
- Corrections preload `unknown_steps` from the latest finalized private session.
- A correction changing known to unknown supersedes the previous event and removes that metric from the current corrected record.

### 4. Downstream missing-data rules

Unknown must remain missing everywhere:

- Unknown bodyweight or calories makes the event ineligible for the existing trend projection, which requires both.
- Unknown calories is not zero and does not trigger the low-calorie safety rule.
- Unknown macros are not calculated from calories.
- Unknown meals are not “off plan.”
- Unknown water is not zero intake.
- Unknown sleep or condition is not normal, poor, or any score.
- Unknown digestion is not normal digestion.
- Unknown appetite/stress is not a neutral score.
- Unknown training is not rest, missed training, or completed training.
- Unknown trigger fields do not activate adaptive follow-ups; they also do not prove the normal branch.
- Adherence and coaching projections must classify missing evidence as missing, never adherent or non-adherent.

### 5. Progress and previous-answer behavior

```text
completed = known fields + unknown fields + optional-note “없음”
total = 12 + active adaptive follow-ups
```

Previous-answer editing preserves the old answer while editing:

- entering edit mode does not change progress;
- replacement known/unknown atomically replaces the old state;
- later base answers remain intact;
- only adaptive answers invalidated by the replacement are removed;
- cancellation restores the original continuation point.

No confirmation card may contain the old or new value.

### 6. Legacy and active calories-step migration

Migration is lazy and additive:

- Missing `unknown_steps` loads as empty.
- Existing `answers` remain known exactly as stored.
- Existing text such as “기억 안남” remains legacy free text; do not reinterpret it.
- Existing finalized drafts and events are not rewritten.
- New fields serialize only on the next successful session mutation.
- Corrupt or contradictory known/unknown state fails closed without rewriting bytes.

For the currently active calories-step draft:

- preserve the same session ID, customer boundary, KST day, version 1, `calories` step, and existing answer;
- infer `unknown_steps=()`;
- report new progress as `1/12`;
- retain the existing binding and expiry;
- do not edit its existing Telegram message during deployment;
- known calories advances once to `macros`, version 2, progress `2/12`;
- unknown calories also advances once to `macros`, but stores no calorie value;
- exact Telegram update replay cannot advance past `macros`;
- deployment or restart creates neither an event nor a replacement draft.

### 7. Required acceptance tests

1. Accepted text and callback answers each cause exactly one new content card and zero old-card edits.
2. Every new card includes confirmation, completed progress, and the next question without the submitted value.
3. Old-card callbacks are stale after the domain commit and cannot mutate state.
4. `not_sent` and ambiguous send outcomes trigger zero automatic retries.
5. Restart from `pending` or `unknown` publishes nothing until explicit resume.
6. Unknown is tested for all 11 supported fields and maps to the exact `None` fields above.
7. Unknown macros omit all three macro values.
8. Optional note exposes no unknown action.
9. Unknown bodyweight/calories produces no trend-eligible row.
10. Unknown values never become zero, normal, rest, or adherence evidence.
11. Known-to-unknown and unknown-to-known corrections supersede exactly once.
12. Legacy drafts load unchanged, and the current calories-step fixture advances exactly once from version 1 to 2.
13. Sentinel answer values are absent from bot text, callback data, logs, bindings, and publication receipts.

## Risks and mitigations

- **Old cards retain clickable keyboards:** version and message/session checks make them harmless; avoiding cleanup is what keeps publication to one robust write.
- **Unknown loses explicit distinction in the canonical event:** the finalized private session retains that distinction, while the event safely expresses only “metric absent.”
- **Partial check-ins may provide insufficient coaching evidence:** downstream logic must report missing evidence rather than infer values.
- **Current prompt uses the former question-position progress convention:** leave that live message untouched; apply completed-count progress on the next answer or explicit resume.