I read this as an advisory-only architecture review - I’ll trace the Telegram adapter, wizard/binding persistence, callbacks, deployment packaging, and tests end to end, then compare viable designs and recommend the smallest decision-complete option without editing files.


I read this as an advisory-only architecture review - I traced the deployed and source-side flow and recommend a gateway-owned durable projection stepper, with no file edits.

# Recommendation

Use a **gateway-owned durable Telegram projection state machine** layered over the existing versioned wizard domain.

Keep `checkin_cli.wizard` as the authority for answers, steps, versions, finalization, and canonical events. Extend the existing binding persistence to durably track each Telegram publication attempt. Do not move Telegram concerns into the profile domain and do not add a database or broker.

This is the smallest architecture that supports separate confirmation and next-question cards without pretending Telegram sends are exactly-once.

## Why the current path is insufficient

The current production path is:

1. `telegram.py` routes `pc1:` callbacks or customer text.
2. `nutrition_coaching.py` resolves the exact customer and delegates.
3. `PhysiqueCheckinBridge` applies a version-checked domain transition.
4. `checkin_cli.wizard` atomically replaces the private session draft.
5. The adapter edits the current Telegram card to the next prompt.
6. `telegram-bindings.json` stores only one active message ID and cursor.

The important gaps are:

- Domain state and Telegram binding state are separate files.
- A crash after domain commit but before binding persistence leaves the domain ahead of the UI.
- Callback replay is safely rejected as stale, but does not repair the missing projection.
- The text renderer retries an uncertain edit and may fall back to a new send; an edit that succeeded before a timeout can therefore produce duplicate cards.
- Polling receipts deduplicate completed updates, but `pc1` has no business-commit recovery equivalent.
- The v1 binding reader silently treats corruption as empty state.
- Telegram `sendMessage` has neither an idempotency key nor a reliable history query. Exactly-once next-card delivery is impossible.

# Preferred module boundaries

| Module | Responsibility |
|---|---|
| `checkin_cli.wizard`, `wizard_models`, `wizard_storage` | Unchanged canonical domain: answers, CAS version, branching, finalization, canonical events. No Telegram IDs or projection phases. |
| `gateway/platforms/physique_checkin.py` | Exact-route authorization, callback parsing, domain command execution, and read-only cursor/prompt reconstruction. Returns content-safe transition metadata. |
| `gateway/platforms/physique_checkin_bindings.py` | Upgrade to a transactional schema-v2 store containing both the active card binding and one durable transition/publication record. No network calls. |
| New `gateway/platforms/telegram_physique_checkin_stepper.py` | Orchestrates prepare, domain commit, next-card send, confirmation edit, duplicate reconciliation, and startup recovery through a narrow transport protocol. |
| `gateway/platforms/nutrition_coaching.py` | Retains customer resolution and finalization journal ownership. Accepts an ingress identity and returns the existing completion notice. It does not manage Telegram delivery phases. |
| `gateway/platforms/telegram.py` | Thin routing and PTB transport implementation. Acknowledge callbacks, construct ingress identity, invoke the stepper, and trigger existing completion handling. |
| `telegram_polling_receipts.py` | Retain as the bot-wide update-offset gate. The stepper must reach a durable terminal publication outcome before the handler returns. |

The profile wheel need not change. It should still be sealed into the combined candidate so the runtime cannot mix an unqualified profile package with the new Hermes wheel.

# State contracts

## Ingress identity

Pass this explicitly from the adapter:

```text
update_id
kind: callback | text
incoming_message_id
actor/chat/topic identity
```

Do not persist raw text, selected values, callback strings, or hashes of customer answers. Callback actions can be reconstructed from a replayed update.

## Domain cursor

```text
session_id
flow
step
version
finalized
awaiting_text
```

The wizard session remains authoritative. The binding cursor is a projection and must be repaired from the domain when they disagree.

## Durable stepper record

Keep the existing top-level binding fields for backward readability and add one transition record per active session:

```text
transition_id
ingress_update_id
ingress_message_id
source_card: message_id, step, version
target_cursor: step, version, terminal
phase
next_message_id?
attempt_generation
expires_at
```

Recommended phases:

```text
PREPARED
DOMAIN_COMMITTED
SEND_STARTED
NEXT_RECEIPTED
COMPLETE
UNKNOWN_DELIVERY
PERMANENT_FAILURE
LEGACY_UNCERTAIN
```

Core invariants:

- At most one nonterminal transition per session.
- `PREPARED.source_card` must equal the active binding.
- Domain mutation uses the source version as its CAS value.
- The active binding does not move to the next card until its provider message ID is durably recorded.
- `SEND_STARTED` without a receipt is never automatically resent.
- Terminal records remain until superseded by a later cursor or expire; they are not deleted immediately after handler return.
- The store contains no customer answer values.
- Corrupt, unsafe, or conflicting state fails closed rather than becoming an empty binding set.

Use the repository’s existing `fcntl`, private-mode, no-follow, temporary-file, `fsync`, and `os.replace` patterns. Also fsync the parent directory after replacement. Never hold the file lock during Telegram I/O.

# Transition sequence

Apply confirmation-plus-next-card only when an answer was actually committed. Launch, clarification, edit-menu navigation, Previous, Defer, invalid input, and stale callbacks should remain in-place/idempotent UI operations.

For an accepted answer:

1. Authenticate exact actor, route, session, source message, step, and version.
2. Best-effort acknowledge callback immediately.
3. Under the stepper lock, write `PREPARED`.
4. Release the lock and execute the existing domain CAS transition.
5. Persist `DOMAIN_COMMITTED` with the resulting cursor.
6. Persist `SEND_STARTED`.
7. Send the next card once.
8. On a valid Telegram receipt, atomically persist `next_message_id`, move the active binding, and enter `NEXT_RECEIPTED`.
9. Edit the old prompt card into a generic, value-free confirmation with no keyboard.
10. Record `COMPLETE`.

**Send the next card before editing the old card.** If delivery becomes uncertain, the old prompt remains visible as a recovery anchor rather than disappearing before the next question is known to exist.

Confirmations should disclose only bounded metadata such as the completed step ordinal or field label, never the submitted value.

For the terminal Save transition, there is no next card: render the terminal completion on the current card. Existing canonical finalization and operator-generation recovery remain separate and must not be rolled back by a customer UI failure.

# Failure semantics

| Failure | Required behavior |
|---|---|
| Duplicate update before domain commit | Match `ingress_update_id`; resume the same transition, never create another. |
| Crash after domain commit but before projection commit | On replay/startup, compare domain cursor to `PREPARED`; if exactly one legal successor, promote to `DOMAIN_COMMITTED`. |
| Crash in `DOMAIN_COMMITTED` before provider call | Startup may make the one automatic send after first persisting `SEND_STARTED`. |
| Crash or timeout after provider call starts, before receipt | Mark `UNKNOWN_DELIVERY`; never automatically resend. |
| Definite local/pre-call failure | The same attempt may remain pending and retry because provider invocation is proven not to have begun. |
| Definite Telegram rejection | Record `PERMANENT_FAILURE`; keep domain progress and require explicit Resume. |
| Confirmation edit timeout | Retry the identical edit; edits to a known message are idempotent. Treat “message is not modified” as success. |
| Confirmation cannot be edited | Record confirmation skipped and keep the successfully bound next card active. |
| Duplicate callback after transition | Acknowledge as stale; no domain mutation. |
| Callback from a next card whose send is `UNKNOWN_DELIVERY` | If route, session, target step/version, and actual message ID match, adopt that message ID as the missing receipt and complete recovery. |
| Typed response after an unknown next-card send | Continue from the authoritative domain cursor; mark the unknown prompt observed without inventing a message ID. Do not try to edit it. |
| Explicit customer Resume after unknown delivery | Supersede the unknown attempt and permit one new, separately recorded recovery send. |
| Binding/projection corruption | Fatal/fail closed before domain mutation and before advancing the polling offset. |
| Expiry with committed but unsent projection | Do not send automatically; retain a tombstone and require explicit Resume. |

This yields **at-most-once automatic send attempts**, not exactly-once delivery. That limitation should be explicit operational policy.

# Restart recovery

Before polling starts, scan every enabled customer stepper record:

- `PREPARED`: compare with the domain cursor; promote only on an exact successor, otherwise await replay or fail closed.
- `DOMAIN_COMMITTED`: send once if still valid and transport authority remains current.
- `SEND_STARTED`: convert to `UNKNOWN_DELIVERY`; never call Telegram.
- `NEXT_RECEIPTED`: retry the old-card confirmation edit.
- `UNKNOWN_DELIVERY`: no provider call; await observed callback/text or explicit Resume.
- `COMPLETE`: no-op.

Revalidate live customer route, consent, pause state, plan window, and candidate authority immediately before any recovered send.

# Migration strategy

1. **Additive schema:** retain legacy binding fields and add a schema-v2 `stepper` section. Existing `pc1` callback grammar remains unchanged, so old cards continue working.
2. **Read both, write v2:** startup accepts v1 and v2. Before polling, migrate all valid v1 files atomically.
3. **Reconcile against domain:**
   - Exact cursor match: preserve active card and in-progress session.
   - Domain exactly one version ahead with no trustworthy publication evidence: mark `LEGACY_UNCERTAIN`; do not automatically send.
   - Missing session, impossible version relationship, or malformed binding: fail closed.
   - Preserve valid UI-only states such as edit-menu or awaiting-text when their domain version matches.
4. **No canonical migration:** do not rewrite wizard drafts, answers, event ledgers, finalization journals, or customer registries.
5. **Rollback rule:** an old runtime may be restored only when no v2 record is nonterminal or unknown. Otherwise use the new candidate in recovery-only/feature-disabled mode and fix forward.

This preserves all existing in-progress sessions without risking a duplicate send based on unverifiable legacy state.

# Atomic sealed deployment

The active production shape already uses a versioned immutable virtual environment containing both distributions. Keep that model:

1. Build and qualify one candidate binding over:
   - Hermes wheel,
   - exact profile wheel,
   - new stepper/migration verifier bytes,
   - source and installed-mode Telegram tests.
2. Install both sealed wheels into a new immutable runtime directory before cutover.
3. Stop the sole polling service and prove no competing process owns the bot.
4. Run the read-only migration preflight, then schema migration before polling starts.
5. Atomically replace the systemd unit/drop-in references to the complete new runtime and matching credentials; never patch the active venv.
6. Start only after installed-wheel provenance, candidate authority, profile binding, state compatibility, and recovery scan pass.
7. Retain the prior runtime, but apply the rollback restriction above.

The current working tree contains substantial unrelated changes, so the deployment input must be a clean sealed candidate, never ambient source bytes.

# Viable alternatives

## A. Gateway projection journal — preferred

- **Coupling:** Low; Telegram state remains at the adapter edge.
- **Testability:** High; transport and crash phases are independently testable.
- **Migration:** Additive binding migration only.
- **Failure cost:** Requires explicit reconciliation between domain and projection files.
- **Why selected:** Existing versioned CAS domain state makes that reconciliation deterministic. It avoids changing the profile package for a transport-only UX.

## B. Generic UI outbox embedded in `WizardSession`

The profile domain would atomically commit the answer and a content-free `UiEffect`; the gateway would drain and receipt it.

- **Coupling:** Medium/high; the profile domain acquires UI publication lifecycle concepts.
- **Testability:** Strong domain atomicity, but every profile test must account for outbox state.
- **Migration:** Every in-progress wizard session schema changes; both wheels must change together.
- **Failure cost:** Telegram receipts and bindings still remain gateway-owned, so a second reconciliation boundary is not eliminated.
- **Why rejected:** It buys atomic intent creation at disproportionate migration and coupling cost. The existing domain CAS already provides enough recovery evidence.

# Other alternatives rejected

- **Commit then send with in-memory retries:** fails restart recovery and uncertain-send handling.
- **Blind retry after timeout:** can duplicate next cards.
- **Single message containing confirmation plus the next question:** operationally simplest, but does not provide the requested separate next card.
- **SQLite, broker, distributed saga, or two-phase commit:** unnecessary for one local gateway and cannot make Telegram participate in an atomic transaction.
- **New callback namespace:** would strand existing in-progress `pc1` cards without improving message-ID binding.

# Blast radius

Expected code impact is limited to:

- one new Telegram stepper module;
- schema-v2 binding persistence;
- small safe-transition APIs in the bridge;
- threading ingress identity through customer callback/text routing;
- startup recovery registration;
- sealed candidate/provenance and local Telegram QA coverage.

Unchanged boundaries:

- no web application;
- no external dependency;
- no callback namespace change;
- no wizard answer/event schema migration;
- no customer registry change;
- no operator draft/delivery redesign;
- no customer-value persistence or logging in the new layer.

Verification was read-only against the source tree, the active installed runtime, wizard persistence, callback/text routes, polling receipts, tests, and sealed dual-wheel deployment machinery. No files were edited and no customer records were inspected or echoed.