I read this as advisory architecture design - I’ll survey NutriCoach end to end, compare at least two minimal invite models, then deliver one decision-complete read-only design with states, gates, migration, and tests.


I read this as advisory architecture design - I surveyed the invite, Telegram, onboarding, registry, activation, routing, and multi-customer boundaries and recommend a provisional first-claim flow that leaves the canonical customer registry unchanged until approval.

## Current architecture and gap

Current targeted flow:

1. `dualcoach_admin customer invite --draft ...`
2. `dualcoach_customer_invite.py` requires a complete `CustomerDraft`: customer key/ID, intended Telegram user, schedule, calories, protein, meals.
3. `telegram_customer_bootstrap.py` stores a hashed `rc1_` token in a private, append-only, generation-CAS ledger; expiry is 24 hours.
4. `/start rc1_...` must arrive in the intended customer’s private DM.
5. `telegram_customer_bootstrap_registration.py` immediately writes a complete disabled `CustomerSpec` to `customers/registry.json`.
6. Consent starts the 22-question onboarding.
7. The canonical owner reviews and approves the questionnaire; finalization replaces the draft nutrition profile/plan and writes readiness artifacts.
8. `dualcoach_activation_cutover.py` requires consent, readiness, membership/checklist evidence, capacity, registry identity, and committed activation receipts before enabling the customer.
9. `NutritionCoachingCoordinator` routes only enabled customers on exact registered identities.

This already has strong token, consent, owner-review, activation, routing, journal, and crash-recovery gates. It cannot support first claim without either inventing false registry values or decoupling pre-approval onboarding from the registry.

## Viable designs

### Option A: provisional first-claim authority outside the registry — recommended

Keep the bootstrap ledger authoritative until questionnaire completion and operator identity approval. Generate the customer key only when the link is claimed. Materialize one complete disabled registry customer during approval.

**Trade-offs**

- Coupling: localized to bootstrap, onboarding runtime, one admission orchestrator, finalization pins, and activation preflight.
- Testability: state transitions and registry transaction recovery are independently testable.
- Migration: no registry schema migration; old `rc1_` sessions normalize as targeted sessions.
- Failure mode: approval spans bootstrap and registry files, requiring a small write-ahead recovery transaction.
- Benefit: active routing and every existing registry consumer continue seeing only complete `CustomerSpec` records.

### Option B: add `pending_customers` to the canonical registry

Claim would append a minimal identity-only pending row. Approval would promote it to a complete customer.

**Trade-offs**

- Coupling: every strict registry reader, writer, migration, digest, receipt, cleanup path, and coordinator reload must preserve and validate the new namespace.
- Testability: duplicate/capacity checks are simpler because they occur in one registry lock.
- Migration: all registry versions and writers need coordinated changes.
- Failure mode: one malformed pending row could make the production registry fail closed, interrupting unrelated active customers.
- Benefit: avoids the cross-file approval transaction.

Option B is viable but has the wrong blast radius. Pending invitations should not become production routing authority.

## Recommended design

### 1. Invitation contract

Add a distinct first-claim mode while preserving targeted invites:

```text
rc1_<22-char token>  targeted_v1, unchanged
fc1_<22-char token>  first_claim_v1
```

CLI:

```text
dualcoach_admin customer invite --profile-root ... --first-claim --json
```

`--first-claim` and `--draft` are mutually exclusive. Existing `--draft` calls remain byte-for-byte compatible.

First-claim output:

```json
{
  "customer_link": "https://t.me/<bot>?start=fc1_<token>",
  "expires_at": "...",
  "generation": 1,
  "session_id": "cb_..."
}
```

It contains no customer key/ID, identity, schedule, or nutrition target. The token remains 128-bit CSPRNG material; only its SHA-256 is persisted.

Issuance validates private profile/config/registry files, bot username, owner identity, and that capacity is not already unavailable. This check is advisory; claim repeats it atomically.

### 2. Bootstrap data additions

Upgrade the bootstrap projection to `telegram-customer-bootstrap-v2`. Each normalized session gains:

```text
invite_kind:
  targeted_v1 | first_claim_v1

customer_draft:
  CustomerDraft | null

assigned_customer_key:
  string | null

capacity_reserved:
  bool

questionnaire_schema:
  targeted_nutrition_v2 | first_claim_nutrition_v1 | null

questionnaire_baseline_digest:
  sha256 | null

admission_inputs_digest:
  sha256 | null

approval_transaction:
  null | {
    phase: prepared | committed,
    approval_id,
    customer_key,
    claim_digest,
    operator_user_id,
    operator_chat_id,
    operator_topic_id,
    operator_message_id,
    operator_update_id,
    consent_handoff_digest,
    questionnaire_baseline_digest,
    admission_inputs_digest,
    candidate_customer_digest,
    registry_before_sha256,
    registry_after_sha256,
    approved_at_kst,
    digest
  }

identity_approval_digest:
  sha256 | null

ready_digest:
  sha256 | null
```

`RoleClaim` remains the authoritative Telegram binding. Usernames and Telegram display names are not identity authority.

For `first_claim_v1`, `customer_draft` and `assigned_customer_key` are null while `PREPARED`. Claim assigns a CSPRNG-generated key such as `nc_<22 chars>` after the link is opened.

### 3. States

Existing targeted flow remains:

```text
PREPARED
-> REGISTERING
-> AWAITING_CONSENT
-> AWAITING_ACTIVATION
-> ACTIVE
```

First-claim flow:

```text
PREPARED
-> AWAITING_CONSENT
-> ONBOARDING
-> AWAITING_IDENTITY_APPROVAL
-> FINALIZING
-> AWAITING_ACTIVATION
-> ACTIVE
```

Terminal states:

```text
CANCELLED | REJECTED | EXPIRED | FAILED | ACTIVE
```

Rules:

- `EXPIRED` applies only to unclaimed `PREPARED` links at `issued_at + 24h`.
- A successful claim consumes the token permanently.
- Consent decline becomes `CANCELLED`.
- Operator rejection becomes `REJECTED`.
- Revision returns `AWAITING_IDENTITY_APPROVAL -> ONBOARDING` and clears questionnaire/approval pins.
- Safety hold remains `ONBOARDING`; identity approval is unavailable until the existing clinical/risk flow returns the questionnaire to owner review.

### 4. Atomic first claim

Create one host-owned `FirstClaimAdmissionService` to coordinate the existing profile authority lock and bootstrap ledger. Do not spread capacity logic through Telegram handlers.

Claim gate, under `profile_authority_lock` then bootstrap lock:

1. Session is `first_claim_v1/PREPARED`.
2. `now < expires_at`.
3. Exact private DM: non-bot actor, no `sender_chat`, `user_id == chat_id`, topic `0`.
4. Token hash and generation match.
5. Actor is not:
   - canonical owner or configured review operator;
   - any existing customer, enabled or disabled;
   - any assigned trainer;
   - the intended or claimed identity of another nonterminal bootstrap session.
6. Capacity occupancy is below the current registry limit.

Capacity occupancy is the union of:

```text
enabled registry customer keys
+
assigned_customer_key for nonterminal first-claim sessions
  where capacity_reserved = true
```

Using a union prevents double-counting a customer if registry activation committed but bootstrap finalization is recovering.

On success, one ledger mutation stores the role claim, generates `assigned_customer_key`, sets `capacity_reserved=true`, advances generation, and enters `AWAITING_CONSENT`. Concurrent claimants receive the generic invalid/expired response. Invalid duplicate or capacity attempts do not consume the token.

### 5. Consent and questionnaire

First-claim consent is rendered from bootstrap authority, not `NutritionCoachingCoordinator`, because no registry customer exists yet. The current consent publication receipt, uncertain-send recovery, callback provenance, and exact private-DM checks remain.

Consent grant transitions to `ONBOARDING`. Before every questionnaire mutation, runtime reloads and verifies:

- bootstrap state and generation;
- exact claimed route;
- current consent handoff;
- canonical owner route;
- no duplicate claim identity.

Do not disable authority validation globally. Add an explicit bootstrap-backed authority validator for `first_claim_v1`; targeted onboarding continues using `validate_current_registry_authority`.

Persist questionnaire schema rather than an arbitrary field list:

```text
targeted_nutrition_v2:
  existing 22 QUESTION_FIELDS

first_claim_nutrition_v1:
  existing 22 fields, then:
  display_name
  starts_on
  daily_time_kst
  weekly_weekday
  monthly_day
```

Validation:

- `display_name`: normalized, 1–80 characters.
- `starts_on`: ISO date; must still permit activation inside the existing 28-day KST plan window.
- `daily_time_kst`: `HH:MM`.
- `weekly_weekday`: 0–6.
- `monthly_day`: 1–28.

At attestation, split the answers into:

- unchanged `NutritionOnboardingBaseline`;
- `FirstClaimAdmissionInputs`;
- separate canonical digests.

Calories, protein, macros, and meal structure are generated only after attestation from the baseline and meal count. No placeholders are ever written.

### 6. Mandatory operator identity approval

When the questionnaire reaches ordinary owner review, transition bootstrap to `AWAITING_IDENTITY_APPROVAL` and publish to the exact canonical owner route.

The card must display:

- stable numeric claimant Telegram user ID;
- requested display name;
- requested schedule;
- generated customer key;
- baseline/plan summary;
- explicit warning that the link may have been forwarded and this exact first claimant will become the customer.

Use a new callback action, `claim_ok`, labelled **“Approve claimant and onboarding”**. Do not reuse the ambiguous targeted `owner_ok` branch.

Approval requires the existing owner-route, member-presence, current-publication, message-ID, callback-generation, and consumed-update gates. It additionally rechecks duplicate identities, capacity, baseline/admission digests, consent, and claim generation.

### 7. Complete disabled registry materialization

Approval derives exactly one complete disabled `CustomerSpec`:

```text
customer_key         assigned_customer_key
display_name         questionnaire admission input
enabled              false
telegram             exact RoleClaim route
trainer              null
schedule             questionnaire admission inputs
profile              registry_profile(baseline)
ai_processing_consent exact consent handoff date + privacy-v1
plan                 registry_plan(generate_initial_plan(...))
```

No registry model change is needed.

Approval transaction:

1. Append `approval_transaction.phase=prepared` to the bootstrap journal with before/after registry hashes and exact candidate customer digest.
2. Atomically append the complete disabled customer under `profile_authority_lock`.
3. Reload and verify the complete registry and exact disabled row.
4. Append `phase=committed`, store `identity_approval_digest`, and enter `FINALIZING`.
5. Finalize existing readiness artifacts, pinning `identity_approval_digest`.
6. Verify readiness and transition to `AWAITING_ACTIVATION`, storing `ready_digest`.

Startup recovery compares registry bytes:

- Equal to `registry_before_sha256`: apply the deterministic prepared mutation.
- Equal to `registry_after_sha256` with the exact row: commit the approval.
- Anything else: fail closed; no activation or automatic overwrite.

This is a local write-ahead transaction, not a general saga or new database.

### 8. Activation gates

For `first_claim_v1`, `dualcoach_activation_cutover._preflight_bootstrap` must require all existing gates plus:

- state exactly `AWAITING_ACTIVATION`;
- committed approval transaction;
- valid `identity_approval_digest`;
- approval claimant equals the sole `RoleClaim`;
- approval operator equals the current canonical owner triple;
- approval baseline/admission digests equal current ready artifacts;
- readiness receipt and pointer pin `identity_approval_digest`;
- registry contains exactly one matching disabled complete row;
- no duplicate identity now exists;
- current capacity still permits conversion of this reservation to enabled.

Staff membership evidence, activation audit, and nutrition activation receipt must also pin the identity approval digest. Direct `activate_customer`, stale CLI evidence, old generations, or manually created readiness artifacts cannot bypass it.

Targeted sessions omit the new digest and continue through their existing intended-identity and owner-review gates. The compatibility branch must be explicit by `invite_kind`, never “missing receipt means legacy.”

## Migration and rollback

- Existing bootstrap v1 ledger/journal entries normalize to:
  - `invite_kind=targeted_v1`
  - `assigned_customer_key=customer_draft.customer_key`
  - `capacity_reserved=false`
  - no identity approval.
- On the next mutation, append a v2 projection; never rewrite old journal events.
- Existing `rc1_` links and targeted CLI invocations remain supported.
- Existing onboarding documents missing `questionnaire_schema` map only to `targeted_nutrition_v2`.
- Existing targeted readiness/activation receipts remain valid.
- First-claim readiness is accepted only when explicitly marked with the new review flow and identity digest.
- `customers/registry.json` requires no migration.
- A default-off `room_bootstrap.first_claim_invites` flag controls new issuance and unclaimed `fc1_` links. Runtime code may continue or reject already-claimed sessions safely; disabling it does not reinterpret them as targeted.
- Rejected/cancelled/failed first claims release their capacity reservation. Historical records remain private and auditable.

## Required tests

### Invite and claim

- First-claim CLI output contains no customer key, identity, schedule, calories, protein, or meals.
- Exact 24-hour boundary with a fake clock.
- Private-DM-only; bot, group, channel, anonymous, and sender-chat updates do not consume the link.
- Concurrent claimants synchronize on a barrier: exactly one winner, no timing sleeps.
- Replay after successful claim is rejected.
- Old `rc1_` targeted behavior remains green.

### Duplicate and capacity

- Reject owner, review operator, trainer, enabled customer, disabled customer, targeted intended identity, and another claimed identity.
- Duplicate rejection leaves the token usable by an eligible claimant.
- Capacity includes enabled customers plus reservations and does not double-count recovering activation.
- N simultaneous claims for N−1 available slots produce exactly N−1 claims.
- Legacy one-customer and configured 2–20 capacity policies both fail closed.

### Questionnaire and approval

- First-claim questionnaire cannot start before consent.
- Exact claimed route only; no generic LLM fallthrough for any nonterminal state.
- Schedule and customer key are created only after claim.
- No nutrition targets exist before attestation.
- Wrong owner, stale card, wrong message, changed membership, changed baseline, or changed schedule rejects approval without registry mutation.
- Approval card and receipt pin the exact claimant.
- Revision clears approval pins; rejection and cancellation release capacity.
- Safety hold cannot expose `claim_ok`.

### Transaction and activation

- Crash before registry write, after registry write, and before approval commit recover deterministically.
- Conflicting registry bytes fail closed without overwrite.
- Concurrent approval callbacks produce one committed row.
- Activation before identity approval, during `FINALIZING`, or with a tampered approval digest fails before calling profile activation.
- Capacity and duplicate identity are rechecked at activation.
- Ready/readiness/membership/activation receipt tampering is rejected.
- End-to-end fake Telegram flow reaches `ACTIVE` only through:
  `fc1 claim -> consent -> questionnaire -> owner claimant approval -> readiness -> activation`.
- Source and installed-wheel tests exercise the same flow.
- Logs/status documents remain answer-redacted and do not expose raw identities beyond the private operator card.

## Decision

Use Option A. It adds one bounded provisional authority and one recoverable local transaction while preserving the strongest existing boundary: the canonical registry contains only complete customers. Do not relax `CustomerSpec`, add nullable schedules/plans, or write placeholder targets.

No files were edited.