I read this as a read-only architecture investigation - I’ll trace activation, registries, staff routes, lifecycle evidence, configuration, and test seams, then return one decision-complete gate design with exact file:line support and no edits or live calls.


The active implementation is in `/home/cube/projects/richard/hermes-agent`; the workspace here is primarily the sealed evidence graph. I found the existing route check only prevents identical chat IDs, while historical evidence asserts zero customer staff memberships without a lifecycle-bound Telegram membership proof. I’m now tracing the exact activation seam, current wheel candidate, lifecycle observer/sealer, and all configured staff-chat sources.


The critical design choice is to classify staff destinations, not merely compare route IDs: group/supergroup staff chats require Telegram membership proof; owner/operator private DMs are single-user routes and must be proven customer-distinct instead. Treating an owner DM as a membership-bearing group would make a strict `left|kicked` gate dependent on undefined/failed Telegram private-chat queries, so the design will enumerate it but prove it through route identity, while querying every membership-capable staff chat.


I read this as a read-only architecture investigation - I traced the current activation, routing, candidate, observer, and seal paths and designed the smallest strict lifecycle membership gate without performing edits, network calls, or live-state reads.

## Decision

Implement a two-wheel, candidate-bound **customer absence from staff chats** gate:

1. Enumerate every configured staff destination.
2. Classify each destination as:
   - `membership_chat`: Telegram group/supergroup; query the customer actor with `getChatMember`.
   - `private_staff_dm`: owner/operator DM; prove `user_id == chat_id`, `chat_id > 0`, and customer actor differs.
3. Accept customer membership status **only** when exactly `left` or `kicked`. Errors, missing chats, `member`, `administrator`, `creator`, or `restricted` fail closed.
4. Require:
   - an armed `chat_member` subscription before the pre-activation observation;
   - a fresh pre-activation observation;
   - continuous hash-chained membership events through the lifecycle;
   - a fresh post-disable/post-lifecycle observation;
   - a final membership seal consumed by the lifecycle seal and cleanup permission.
5. Never call Telegram membership-mutating methods.

This requires changing both the Hermes wheel and profile `checkin_cli` wheel. Keeping the profile wheel unchanged would leave the raw `customer_admin activate` path able to bypass the gate.

## Why the current implementation is insufficient

- The registry only enforces owner/customer route inequality, not provider membership: `checkin_cli/customer_coaching.py:1055-1061`.
- `reject_customer_staff_membership` merely validates caller-supplied chat IDs and has no production caller: `gateway/platforms/telegram_nutrition_addresses.py:189-208`.
- Existing `get_chat_member` checks whether the callback actor belongs to the callback’s current route; it does not enumerate staff chats or prove customer absence:
  `telegram_nutrition_onboarding_runtime_authority.py:202-212`,
  `telegram_nutrition_onboarding_runtime_callback.py:221-235`.
- Activation proceeds directly from bootstrap/registry preflight into `admin.activate_customer`: `dualcoach_activation_cutover.py:85-115`.
- The activation journal currently binds customer, registry, audit, timestamps, and transaction, but no staff inventory or membership evidence: `customer_admin.py:994-1063`.
- The lifecycle observer subscribes only to recursive filesystem inotify: `continuous_lifecycle.py:1421-1425`; its stated subscription is filesystem-only at `:1408`.
- The lifecycle seal verifies activation and candidate hashes but contains no membership assertion: `verify_lifecycle_seal.py:53-75`.
- Telegram already polls all update types without dropping pending updates, providing the correct transport foundation: `telegram.py:1718-1719`, `:1845-1846`, `:2604-2605`, `:2637-2638`.

## Staff inventory

Add `StaffChatInventory` generation from the effective profile configuration and registry. Deduplicate by `chat_id`, preserving every source path.

Required sources:

- Registry owner route: `RegistryDocument.owner`, `customer_coaching.py:1006-1012`.
- `nutrition_coaching.operator_review`: `telegram.py:5358`.
- `nutrition_coaching.operator_card_route`: `nutrition_coaching_config.py:75,90`.
- `adaptive_nutrition.review_operator` / canonical `operator_chat_id`: `nutrition_coaching_config.py:122,156`.
- `group_allowed_chats`: `telegram.py:12902-12918`; config bridging occurs at `gateway/config.py:985-986,1175-1179`.
- Any configured negative `allowed_chats` and `group_topics[*].chat_id`, conservatively treated as staff-accessible group destinations.

Each canonical inventory row:

```json
{
  "chat_id": "-100...",
  "kind": "membership_chat",
  "roles": ["owner_review", "adaptive_review"],
  "sources": [
    "registry.owner",
    "platforms.telegram.extra.adaptive_nutrition.review_operator",
    "platforms.telegram.extra.group_allowed_chats"
  ]
}
```

Private DM row:

```json
{
  "chat_id": "869...",
  "kind": "private_staff_dm",
  "staff_user_id": "869...",
  "roles": ["operator_card"],
  "sources": ["platforms.telegram.extra.nutrition_coaching.operator_card_route"]
}
```

Unknown, contradictory, zero, or positive non-DM staff routes fail configuration validation.

### Owner/operator remains customer-free

A private Telegram DM is not a multi-member chat. Do not treat a failed `getChatMember(owner_dm, customer)` call as absence. Instead require:

- `staff_user_id == chat_id > 0`;
- customer route remains private, topic `0`, as already required by `telegram_nutrition_addresses.py:82-103`;
- customer actor differs from every private staff DM actor and chat;
- group review destinations independently pass customer `left|kicked`.

Thus the owner/operator can remain in staff groups and use their own DM while the customer has no staff-group membership.

## Source and CLI changes

### New source

`/home/cube/projects/richard/hermes-agent/gateway/platforms/telegram_staff_membership_gate.py`

Responsibilities:

- Parse and canonicalize inventory.
- Implement a narrow read-only Telegram protocol exposing only:
  - `get_me`
  - `get_chat_member`
- Build pre/post observations.
- Validate exact `left|kicked`.
- Manage a mode-`0600`, hash-chained journal:
  `data/onboarding/telegram-staff-membership-v1/events.jsonl`.
- Bind candidate, wheels, customer, inventory, registry/config digests, timestamps, activation journal, bootstrap identity, and subscription epochs.

### Existing Hermes source

1. `gateway/platforms/telegram.py`
   - Import/register `ChatMemberHandler`.
   - Register it after the polling receipt begin handler and before the receipt completion handler, e.g. group `-500`; existing receipt boundaries are at `telegram.py:2342-2349`.
   - Register before updater startup.
   - Append sanitized `chat_member` rows containing only update ID, chat ID, customer user ID, old/new status, Telegram event date, and hash-chain fields.
   - Arm only after verifying the bot itself is `administrator|creator` in every membership chat.

2. `gateway/platforms/dualcoach_activation_cutover.py`
   - Before `admin.activate_customer` at `:104`, require the current subscription epoch and perform a fresh read-only preflight.
   - Pass the resulting evidence file into profile activation.
   - After committed activation validation at `:111`, append an activation-binding row containing the canonical activation-journal SHA-256.
   - On crash/retry, reconcile that binding before advancing bootstrap to `ACTIVE`.

3. `gateway/platforms/dualcoach_admin.py`
   - Extend `customer activate` at `:917-924` with required `--deployment-receipt`.
   - Add:
     - `customer staff-membership-preflight`
     - `customer staff-membership-finalize`
     - `customer staff-membership-verify`
   - JSON output must contain digests/statuses only, never token, usernames, or names.

4. `pyproject.toml`
   - Retain the existing `dualcoach_admin` entry point at `:300`; no new console executable is needed.

### Profile package

Modify the authoritative `checkin_cli/customer_admin.py`, represented in the shipped wheel snapshot at:

`.omo/evidence/task26/task26-owner-customer-v1-candidate-v3/snapshot/shipped-wheel-tree/checkin_cli/customer_admin.py`

Changes:

- `activate_customer` at `:864` takes required `staff_membership_evidence_path`.
- Validate it under `profile_authority_lock` before any adaptive initialization or writes.
- Require matching:
  - customer key and Telegram user ID;
  - current registry SHA-256;
  - current config SHA-256;
  - bootstrap session/generation;
  - candidate digest and both wheel digests;
  - canonical inventory digest;
  - one `left|kicked` result for every membership chat;
  - private-DM identity-separation results;
  - armed subscription epoch;
  - bounded freshness.
- Add `staff_membership_evidence_sha256`, `staff_chat_inventory_sha256`, and `membership_subscription_epoch_id` to:
  - activation audit record built at `customer_admin.py:1338`;
  - prepared/committed journal at `:1007-1063`.
- Validate those fields in `_require_committed_activation_receipt` at `:1785`.
- Make `load_runtime_customer_registry` continue to fail closed through that committed receipt at `:1977`.
- Make the profile `activate` CLI argument mandatory near `customer_admin.py:6845-6849`.

## Evidence contract

Every observation row must include:

```text
schema/version
phase = pre_activation | transition | post_lifecycle
candidate full/core digest
candidate inventory digest
Hermes wheel SHA-256
profile wheel SHA-256
deployment receipt SHA-256
customer key and Telegram user ID
bootstrap session ID and generation
registry SHA-256
config SHA-256
staff inventory and inventory SHA-256
subscription epoch ID
request_started_at_utc
observed_at_utc
Telegram update ID/date where applicable
per-chat status
previous row SHA-256
row SHA-256
activation journal SHA-256, once committed
```

No raw Telegram response, names, token, message body, or customer answer belongs in evidence.

The lifecycle succeeds only if:

- subscription epoch predates the pre-activation query;
- every queried group starts `left|kicked`;
- no transition row enters any other status;
- polling receipt continuity has no gap or overflow;
- any restart is reconciled with pending updates and a new `getChatMember` snapshot;
- final observation occurs after disable and is `left|kicked` for every group;
- inventory/config/registry/candidate bindings remain unchanged;
- activation-journal digest matches the committed journal.

The six-hour lifecycle cap already recorded by the observer is comfortably inside Telegram’s pending-update retention window; the current observer records that cap at `continuous_lifecycle.py:1408-1417`.

## Lifecycle and cleanup evidence successors

Do not modify immutable prior evidence. Create successors:

1. `.omo/evidence/task26/task26-<membership-successor>-<digest>/`
   - candidate manifest, inventory, seal, verifier, both wheels.
2. `.omo/evidence/task26/task26-continuous-lifecycle-observer-v7-<digest>/`
   - consume membership journal;
   - watch its directory before initial snapshot;
   - fail immediately on non-absent transition or subscription discontinuity.
3. `.omo/evidence/task26/task26-live-lifecycle-evidence-seal-v2-<digest>/`
   - include pre/final observations, journal root, activation-journal digest, inventory digest, and subscription continuity.
4. `.omo/evidence/task26/task26-post-lifecycle-cleanup-v5-<digest>/`
   - require the membership seal before cleanup.
   - Current cleanup already pins candidate, both wheels, and lifecycle seal at `cleanup_controller.py:265-294`; add the membership seal/root there and to its permission contract.

The current candidate already binds candidate and both wheels in one manifest:
`task26-customer-service-state-bootstrap-successor-.../candidate-manifest.json:1`.
The new successor must similarly include both changed wheel artifacts and all new source/tests.

## Deterministic tests

### New tests

`tests/gateway/test_telegram_staff_membership_gate.py`

Use a scripted fake Bot; no network and no sleeps.

Cover:

- every configured source is enumerated;
- duplicate topics/chat sources collapse to one query while retaining all sources;
- omitted known staff route fails inventory completeness;
- `left` and `kicked` pass;
- every other status and every API error fail;
- every membership chat is queried exactly once in sorted order;
- private owner/operator DMs receive no membership query and require customer-distinct identity;
- bot lacking admin status fails subscription readiness;
- no method other than `get_me/get_chat_member` is invoked;
- canonical serialization and row hashes are stable.

### Existing tests to extend

- `tests/gateway/test_dualcoach_activation_cutover.py`
  - failed preflight causes zero activation writes;
  - successful preflight is passed into profile activation;
  - crash after activation reconciles the activation-journal binding;
  - stale candidate, inventory, registry, generation, and timestamp fail;
  - retry remains idempotent.
- `tests/gateway/test_telegram_nutrition_addresses.py`
  - retain current typed-route tests at `:152-172`;
  - add inventory classification and private-DM separation cases.
- `tests/gateway/test_telegram_polling_receipts.py`
  - membership event is durably written before polling receipt completion;
  - duplicate update IDs are idempotent;
  - missing or regressing update IDs fail continuity.
- Profile `checkin_cli` activation tests:
  - journal/audit contain exact membership evidence digest;
  - tampered receipt fails before registry/audit/journal mutation;
  - committed receipt validation rejects removed/stale membership fields.
- Observer v7 tests:
  - subscribe to the exact fake journal-write event before triggering it;
  - joining and then leaving still permanently fails the lifecycle;
  - restart continuity and post-lifecycle finalization;
  - no polling/sleep-based timing tests.
- Seal verifier tests:
  - independently tamper candidate, wheel, inventory, timestamps, registry, activation journal, one chat result, one transition, and final observation.

## Live preflight procedure

No part of this procedure was run.

1. Build Hermes and profile wheels twice offline; require byte identity.
2. Seal a successor candidate containing both wheels, source/test hashes, and verifier.
3. Deploy that exact candidate and restart the gateway.
4. Confirm the membership subscription readiness receipt reports:
   - `allowed_updates` includes `chat_member`;
   - `drop_pending_updates=false`;
   - bot `administrator|creator` in every membership chat;
   - current candidate/wheel/config/inventory digests.
5. Run:

```bash
dualcoach_admin customer staff-membership-preflight \
  --profile-root /home/cube/.hermes/profiles/dualcoachtest \
  --customer-id <customer-key> \
  --bootstrap-session <session-id> \
  --expected-generation <generation> \
  --deployment-receipt <sealed-deployment-receipt> \
  --json
```

6. Require every membership chat to report exactly `left` or `kicked`; confirm private staff DMs report identity separation.
7. Run canonical `dualcoach_admin customer activate ... --deployment-receipt ...`; it must perform another fresh query rather than trusting the earlier display preflight.
8. Verify the committed activation journal contains the membership evidence and inventory digests.
9. Execute the lifecycle with the v7 observer armed before customer action.
10. Disable the customer and delivery through canonical commands.
11. Run `staff-membership-finalize`; require final `left|kicked` for every membership chat.
12. Seal and independently verify membership evidence, then lifecycle evidence.
13. Only a cleanup-v5 permission bound to those seals may authorize cleanup.

## Non-mutation guarantee

The implementation’s Telegram interface must not expose `banChatMember`, `unbanChatMember`, `restrictChatMember`, invite creation, approval, or any customer membership mutation. The gate observes only. If the customer is present, activation stops and an operator removes them outside this tool.

No files were edited, no tests were run, and no network or live Telegram/profile state was accessed for this design.