I read this as an advisory architecture task - I’ll trace the onboarding, safety-hold, outbox, persistence, and Telegram authorization paths end to end, then compare two minimal reliability contracts and recommend a decision-complete design without editing files.


I read this as an advisory reliability-design task - I traced the live onboarding path and recommend an additive acknowledgment sidecar that preserves frozen v1 evidence while enabling bounded, restart-safe operator recovery.

## Findings

The reported state is internally consistent but the contract is too weak:

- The frozen profile records onboarding publication generation 26, message 60 as `COMMITTED` in both the profile wrapper and gateway outbox:
  - `.omo/evidence/v1.1.1-stability/rollback-v1.1.0/profile.snapshot/data/customers/pilot_20260820_01/nutrition-onboarding/session.json:1`
  - `.omo/evidence/v1.1.1-stability/rollback-v1.1.0/profile.snapshot/data/onboarding/telegram-publication-outbox-v1/ledger.json:1`
- That outbox record targets owner DM `8693203710/0`, has a valid authenticated receipt, and says only that Telegram returned `message_id=60`. It does not prove display, reading, or human awareness.
- `COMMITTED` currently terminates replay after the profile and outbox receipts are written; preserved records therefore suppress publication after restart or candidate upgrade (`telegram_nutrition_onboarding_runtime_publication_transport.py:267-437`).
- Safety-hold rendering intentionally creates no actions (`telegram_nutrition_onboarding_runtime_publication.py:187-195`; `telegram_nutrition_onboarding_copy.py:117-126`).
- Existing owner callbacks are accepted only from the exact current publication, route, message, owner membership, and domain authority (`telegram_nutrition_onboarding_runtime_callback.py:167-285`).
- Startup already has the right recovery seam: Task26-authorized background recovery scans `AWAITING_ACTIVATION` sessions and calls `recover_waiting_session` (`telegram.py:5368`, `telegram.py:5497-5524`; `telegram_nutrition_onboarding_runtime.py:182-219`).
- The customer remains disabled, bootstrap remains `AWAITING_ACTIVATION`, activation authority is absent, and the workflow remains `safety_hold`. These are explicitly frozen migration invariants (`docs/dualcoach-v1.1.1-migration.md:97-102`, `152-168`).

The defect is semantic: **provider acceptance and human acknowledgment are one state today.**

## Options

### Option A: Add a safety-hold attention sidecar

Leave every existing onboarding and outbox record byte-for-byte unchanged. Add a separate, candidate-independent ledger for operator awareness, reminder attempts, and acknowledgment.

**Trade-offs**

- Coupling: low. Touches onboarding publication rendering, startup recovery, callback routing, operator menu, and one new store.
- Testability: high. The state machine and scheduler can use injected clocks and fake Telegram transport.
- Migration cost: additive; no v1 outbox rewrite or receipt re-signing.
- Failure mode: two correlated ledgers. Mitigate by requiring the immutable original publication as the authority anchor before every transition.
- Rollback: old binaries ignore the sidecar; it must be archived and restored on re-upgrade to retain deduplication.

### Option B: Replace outbox v2 with a unified v3 delivery/acknowledgment outbox

Migrate `COMMITTED` into `PROVIDER_ACCEPTED`, add child reminder attempts and `ACKNOWLEDGED`, and make the outbox the sole source of truth.

**Trade-offs**

- Coupling: high. Changes strict outbox parsing, emergency receipts, callback receipts, migration seals, installed artifacts, and rollback compatibility.
- Testability: conceptually cleaner because there is one ledger.
- Migration cost: high. The frozen outbox hash changes, every existing HMAC-bound record needs migration semantics, and v1.1.0 rejects unknown schemas.
- Failure mode: a bad migration can make the entire onboarding runtime unavailable, not merely reminders.

## Recommendation

Use **Option A**.

Changing the meaning or bytes of frozen `COMMITTED` receipts is unnecessary and conflicts with the current immutable-evidence contract. The sidecar solves the actual problem without turning a notification reliability fix into an outbox migration.

## Recommended contract

### Invariants

1. Existing `COMMITTED` remains immutable and is interpreted as **provider accepted and locally receipted**, never “delivered,” “visible,” or “read.”
2. Only an explicit authenticated owner action produces human `ACKNOWLEDGED`.
3. Acknowledgment:
   - does not set `owner_reviewed`;
   - does not clear `safety_hold`;
   - does not call `record_clinical_review`, `review_as_owner`, or `finalize`;
   - does not enable the registry customer;
   - does not create activation authority;
   - never sends to the customer or invokes an LLM/model provider.
4. Reminder and manual-recovery text is static and owner-only.
5. Every operation revalidates the current safety-hold authority. Read or authority failures fail closed without sending.

### Attention-case state machine

```text
ABSENT
  -> AWAITING_ACK
       when an exact current committed safety_hold publication is proved

AWAITING_ACK
  -> ACKNOWLEDGED
       on an exact authorized owner callback
  -> AUTO_EXHAUSTED
       after all three automatic reminder slots are reserved
  -> SUPERSEDED
       only when trusted current state proves safety_hold ended

AUTO_EXHAUSTED
  -> ACKNOWLEDGED
       through the original card, a reminder, or owner self-service
  -> SUPERSEDED
       when the hold ends through a separate authorized workflow
```

Authority read failures do not produce `SUPERSEDED`; they merely suspend work until the next trigger.

Each supplemental attempt has its own state:

```text
RESERVED
  -> PROVIDER_ACCEPTED   Telegram returned a positive message_id and it was persisted
  -> DELIVERY_UNKNOWN   provider call may have happened but no durable receipt exists
  -> NOT_SENT           positive proof that no provider call/effect occurred
```

A process finding `RESERVED` after restart converts it to `DELIVERY_UNKNOWN`; it never repeats that attempt. Automatic slots are consumed at reservation, so crashes cannot evade the bound.

### Persisted schema and events

New private directory:

```text
data/onboarding/telegram-safety-hold-attention-v1/
  ledger.json       mode 0600
  .lock             mode 0600
  .event-key        mode 0600
```

Directory mode is `0700`. Use the existing atomic temp-write, `fsync`, rename, and `flock` style from the publication outbox rather than introducing a database.

`ledger.json` contains:

```text
schema: telegram-safety-hold-attention-v1
events: [...]
head_integrity: HMAC-SHA256
```

Every event has:

```text
event_id
event_type
case_id
occurred_at_utc
previous_integrity
data
event_integrity
```

`case_id` is candidate-independent:

```text
sha256(domain, customer_key, session_id, generation,
       dispatch_identity, receipt_integrity)
```

Allowed events:

- `CASE_OPENED`
  - immutable original anchor: customer key, session ID, generation, dispatch identity, receipt integrity, payload digest, original message ID and owner route;
  - `original_ack_capable`;
  - discovery/provider-acceptance time.
- `ATTEMPT_RESERVED`
  - deterministic attempt ID;
  - kind `AUTO` or `MANUAL`;
  - automatic slot or triggering update ID;
  - owner route and callback-token digest.
- `ATTEMPT_PROVIDER_ACCEPTED`
  - attempt ID, message ID, route, acceptance timestamp, signed receipt integrity.
- `ATTEMPT_DELIVERY_UNKNOWN`
  - attempt ID and bounded reason code; no exception text or customer content.
- `ATTEMPT_NOT_SENT`
  - attempt ID and positive no-side-effect reason.
- `OWNER_ACKNOWLEDGED`
  - actor user ID, chat/topic/message ID, Telegram update ID, callback data digest, source `ORIGINAL`, `REMINDER`, or `OPERATOR_MENU`.
- `CASE_SUPERSEDED`
  - trusted successor state and its authority digest.

Logical event append is implemented by atomically replacing the document. Event IDs make replay idempotent:

- open: `open:<case_id>`
- automatic reservation: `auto:<case_id>:<slot>`
- manual reservation: `manual:<case_id>:<update_id>`
- acknowledgment: `ack:<case_id>:<update_id>`

### Authority checks

Before opening a case, sending, manually recovering, or acknowledging, require all of:

1. Exact profile wrapper is `COMMITTED` with payload state `safety_hold`.
2. Exact authenticated outbox record matches session, generation, payload, message ID, owner role, route, dispatch identity, and receipt integrity.
3. Bootstrap session is still `AWAITING_ACTIVATION`.
4. Workflow is still `safety_hold`, cursor is 22, `owner_reviewed=false`, and the baseline/reconciliation bindings validate.
5. Registry customer remains disabled with current consent.
6. Activation journal remains absent.
7. Current registry owner user ID and owner route match the operation.
8. Callback actor equals the current owner.
9. Callback message route and message ID match either:
   - the original current publication, or
   - the exact accepted supplemental attempt.
10. The callback update ID has not already been consumed for a conflicting event.
11. `get_chat_member` does not report `left` or `kicked`, matching the existing callback authority seam (`telegram_nutrition_onboarding_runtime_authority.py:152-213`).

Any mismatch is consumed and rejected without domain mutation.

### Buttons and callback routing

For future safety-hold publications, add one button:

```text
확인했습니다 - 임상 승인 아님
```

Use the existing `non2` callback with action `hold_ack`. It remains bound to the original session, generation, message, and route.

Existing message 60 cannot gain this button without rewriting or editing frozen evidence. Supplemental recovery cards therefore use a separate namespace:

```text
nha1:a:<opaque-attempt-token>
```

The token is derived from the sidecar key, case ID, and attempt ID. Its callback validates the exact supplemental receipt before acknowledgment.

### Automatic recovery and throttling

All times are persisted UTC; scheduling uses Asia/Seoul for quiet hours.

For a newly produced, acknowledgment-capable safety-hold card:

1. auto slot 1: provider acceptance + 30 minutes;
2. auto slot 2: provider acceptance + 6 hours;
3. auto slot 3: provider acceptance + 24 hours.

For a legacy committed card without a button, including message 60:

1. auto slot 1: immediately on first successor discovery;
2. auto slot 2: discovery + 6 hours;
3. auto slot 3: discovery + 24 hours.

Rules:

- Automatic sends occur only from 09:00 inclusive to 21:00 exclusive KST; otherwise defer to the next 09:00.
- At most three automatic provider calls per case, including unknown outcomes.
- If any manual or automatic attempt called the provider within the preceding 30 minutes, defer the due automatic slot until that interval expires.
- `ACKNOWLEDGED` or `SUPERSEDED` cancels every unsent slot.
- A `RESERVED` attempt is never called again after restart.
- Fixed absolute due times prevent restarts from extending or resetting the schedule.

The startup trigger belongs directly after current `recover_waiting_session` authority reconciliation. A live scheduler task handles future due times; every wake-up reacquires the ledger claim and revalidates authority before sending.

### Operator self-service

Extend the existing owner-only `/nutritionops` console (`telegram_nutrition_operator_console.py:11-239`; dispatch at `telegram.py:14593-14616`) with:

- `안전 보류 알림`
- list of active held customers;
- detail showing acknowledgment state and reminder history;
- `확인했습니다 - 임상 승인 아님`;
- `알림 다시 받기`.

Manual recovery limits:

- one reservation per Telegram update ID;
- minimum 10 minutes between manual provider calls for a case;
- maximum three manual provider calls in a rolling 24-hour window;
- manual recovery remains available after automatic exhaustion;
- owner authority is rechecked before both reservation and send.

Register an explicit owner-DM command scope containing `/nutritionops` and `/holdstatus`. The current role menu has only customer/staff scopes and maps positive chats to customer commands (`telegram_nutrition_menu.py:44-69`), so a chat-specific owner scope is needed for discoverability.

### Exact current-state recovery

On the first authorized successor startup:

1. Validate the frozen message-60 publication and all current hold/activation invariants.
2. Append `CASE_OPENED` with `original_ack_capable=false`.
3. Reserve legacy auto slot 1 atomically.
4. Send one static recovery card only to `8693203710/0`.
5. Persist `PROVIDER_ACCEPTED` with its new message ID, or `DELIVERY_UNKNOWN`.
6. Keep the onboarding wrapper, outbox record, 22 answers, hold, owner review, registry, bootstrap, and activation absence unchanged.

A restart at any point either resumes scheduling or observes the reserved/accepted attempt; it does not repeat the same provider call.

## Migration and rollback

- Do not migrate or rewrite the v1 wrapper, outbox, receipts, workflow, baseline, or ingress receipts.
- The existing pre/post migration semantic root must remain unchanged. Sidecar creation is a separately authorized post-start operational delta, not part of candidate binding migration.
- Add a successor operational seal with two roots:
  - frozen-v1 semantic root, unchanged;
  - attention-ledger root, separately validated.
- Future profile copies must preserve the sidecar and key byte-for-byte. Candidate digest must not participate in `case_id`, or every upgrade would reopen reminders.
- A rollback after sidecar creation must archive the sidecar with the failed candidate profile and bind its digest in the rollback receipt. The old binary may ignore it.
- Before a later re-upgrade, restore the exact archived sidecar before startup; otherwise the legacy case could reopen and send duplicate reminders.
- Do not merge sidecar data into a frozen v1 rollback snapshot unless the rollback verifier is explicitly updated to recognize it as additive operational evidence.
- The currently approved live-upgrade authorization does not authorize this new owner message or sidecar creation. Deployment needs a narrowly amended authorization covering those two effects.

## Required tests

1. **Frozen fixture:** discover the exact generation-26/message-60 shape; assert all v1 source files remain byte-identical.
2. **Semantic distinction:** provider acceptance leaves case `AWAITING_ACK`; only explicit callback reaches `ACKNOWLEDGED`.
3. **Future card:** safety-hold publication contains `hold_ack`; current preserved message 60 is not republished.
4. **Legacy upgrade:** concurrent startup scans produce exactly one immediate recovery attempt.
5. **Crash matrix:** before provider call, after provider call before receipt, after receipt, and after acknowledgment before ingress receipt.
6. **Duplicate safety:** concurrent reservation, duplicate callback/update, and repeated process restart converge without another provider call.
7. **Scheduler:** injected clock proves exact 30-minute/6-hour/24-hour boundaries, KST quiet-hour deferral, three-slot cap, and cancellation after acknowledgment. No sleeps or polling tests.
8. **Manual throttling:** 10-minute minimum, three-per-24-hour limit, update-ID dedupe, and availability after automatic exhaustion.
9. **Authority matrix:** wrong actor, route, topic, message, token, generation, owner, bootstrap state, registry state, activation presence, or tampered receipt all cause zero sends and zero mutation.
10. **Unknown outcome:** a reserved/unknown attempt is never retried, including after restart.
11. **Hold resolution:** trusted transition away from `safety_hold` supersedes the case; read errors do not.
12. **No activation/coaching:** spies assert zero customer transport, model-provider requests, registry enablement, activation journal creation, finalization, owner review, or clinical clearance calls.
13. **Ingress recovery:** crash after durable acknowledgment but before Telegram polling receipt reconciles through the existing business-recovery seam.
14. **Migration/rollback:** old semantic seals remain identical; sidecar is copied forward, archived on rollback, and restored on re-upgrade without reopening slots.
15. **Packaging parity:** source and installed-wheel behavior produce identical state, callbacks, and scheduling.

## Residual risks

- Telegram offers no reliable bot-side “message was displayed/read” API. Explicit acknowledgment proves a button action, not comprehension.
- If the owner cannot locate or open the bot at all, same-bot reminders cannot solve reachability; that requires an external operational paging channel.
- Bounded duplicate notices are intentional when Telegram accepted a message that remained unseen. The contract bounds them rather than pretending acceptance proves visibility.

No files were edited and no tests were run; this was advisory-only architecture work.