# Task 1789 — Re-seating a channel row forks it to webchat and destroys the channel seat

Design, 2026-07-20.

## The defect

`session-reseat.ts` builds every fork payload with a hardcoded webchat shape. Re-seating a
WhatsApp or Telegram row therefore spawns a **webchat** session whose `senderId` is
`session:<newId>`. The per-sender channel server for the real sender is replaced, its SSE stream
aborts, and `whatsapp/gateway/routes.ts:56` fires `op=channel-detached`. The fork renders with a
webchat glyph.

The fork is also unreachable from the channel. A sender's session id is
`adminSessionIdFor(accountId, senderId)` — `sha256` shaped into a v4 UUID, a pure function. Every
future inbound recomputes the original id, never the fork's.

Observed 2026-07-18 on `sitedesk-code`, account `5e99bba5`, sender `447504472444`, session
`c6ed890f`. Brand-uniform code path.

## Three corrections to the task brief

The brief was written against a reading of the code that does not hold. All three were confirmed
by reading the tree and were agreed with the operator before this design was written.

**The seam named in Fix #2 is log-only.** `wa-gateway.ts:312`'s `adminSessionIdFor` call feeds the
`op=inbound … sessionId=` line and nothing else. The id the spawn actually runs under comes from
the pure builder `whatsapp/gateway/spawn-request.ts:34`, called once at `server/index.ts:418`.
Telegram mirrors it exactly (`spawn-request.ts:31`, `server/index.ts:700`). Consulting the
override at the gateway line alone would make the log truthful and route nothing.

**The fork has no channel binding until the next inbound.** The manager wires the WhatsApp reply
tool only when a `waChannel` descriptor (senderId, gatewayUrl, serverPath) is present. The re-seat
route holds none of those, and the manager's store fallback is keyed on the new session id, which
is empty for a fresh fork. So the fork spawns correctly labelled but without
`mcp__whatsapp-channel__reply` until an inbound arrives and `ensureChannelSession` binds it. This
is accepted, not fixed — the existing re-attach path already covers it. It is made observable
rather than left silent, because the manager's own `op=channel-binding-lost` invariant is gated on
the store and cannot fire here.

**The audit sweep cannot answer the standing check as specified.** `runDuplicateSenderAudit`
walks session sidecars on disk. It has no event history, so "a sender whose last event is a
`channel-detached`" is not derivable from it. The gateway must record detach times for the sweep
to query.

## Scope

In scope: WhatsApp and Telegram admin channel rows.

Out of scope, deliberately:

- **Public channel rows.** Public sessions key their id on `personId` as well as `senderId`, so
  the `<channel>:<senderId>` override key is ambiguous for them. Public re-seat is not evidenced
  as broken. Public rows re-seat exactly as they do today.
- **Telegram in the standing audit.** `runDuplicateSenderAudit` is WhatsApp-only today. The
  unseated check extends it in place; making the whole sweep multi-channel is separate work.
- Everything already listed under the task's own "Out of scope": the fork's displayed model
  (Task 1790), the cross-lever clobber (Task 940), re-attaching the live sender, and
  `op=duplicate-sender-sessions` for `447850009518`.

## Design

### Unit 1 — `platform/ui/server/channel-session-override.ts` (new)

Modelled on `canonical-webchat-override.ts`. One JSON file, `channel-session-override.json`, in
the account directory.

```json
{ "bySender": { "whatsapp:447504472444": "fc397054-…" } }
```

- `channelOverridePath(accountDir)` — the file path.
- `readChannelOverrideId(accountDir, channel, senderId): string | null` — the fork id for that
  sender, or null when unset, malformed, or not a v4-shaped UUID. Never throws; a corrupt file
  reads as null so a bad write can never wedge inbound routing.
- `writeChannelOverrideId(accountDir, channel, senderId, sessionId): void` — merges into
  `bySender`, preserving other senders' entries. Atomic tmp+rename so a concurrent read never
  sees a half-written file.

There is no legacy-shape migration. Unlike the canonical override this file has no predecessor.

### Unit 2 — the fork inherits the source row's channel

`session-reseat.ts` reads the source session's `.meta.json` through the existing exported
`readSidecarMeta`, and passes the result to `resolveReseatPlan` as a new optional `source`
argument. `resolveReseatPlan` stays pure and unit-testable without the gate.

The channel shape applies when **all three** hold: sidecar `role` is `admin`, sidecar `channel` is
`whatsapp` or `telegram`, and sidecar `senderId` is a non-empty string. Then the payload carries:

- `channel` — the sidecar's channel
- `senderId` — the sidecar's senderId
- `role: 'admin'`
- `targetAccountId` — the sidecar's `accountId`, when present
- **no** `webchatChannel` descriptor

`personId` is never carried: the admin-only scope means it is always null on these rows.

When any of the three conditions fails, the payload is byte-for-byte what it is today.

`targetAccountId` is an addition beyond the task text and was approved explicitly. Without it the
manager defaults the fork to the boot account, so a channel row belonging to a client sub-account
would fork into the house account — wrong account scope for every Neo4j write the fork makes.
Pre-983 sidecars carry no `accountId`; those omit the field and keep today's behaviour.

### Unit 3 — resolution consults the override

`buildWaSpawnRequest` and `buildTelegramSpawnRequest` each gain an optional `overrideSessionId`
input. When set it is used as `sessionId`; otherwise `adminSessionIdFor` as today. The builders
keep no filesystem access and stay pure.

`server/index.ts` reads the override at both spawn call sites (`:418` WhatsApp, `:700` Telegram),
resolving the effective account's directory by id through `listValidAccounts()`. Using the
**effective** account id on the read side matches the write side, which keys on the source
sidecar's `accountId` — so an account-manager-routed sub-account sender resolves its own override
and never the house's.

The two gateway `op=inbound` log lines consult the same override, so the logged `sessionId` names
the session the message actually reaches. The gateways do not read the file themselves: each takes
an optional `resolveSessionOverride?: (accountId, senderId) => string | null` dependency, injected
by `server/index.ts` at construction. The gateways keep no filesystem knowledge and stay testable
with a stub, and an absent dependency degrades to the deterministic id.

### Chained re-seats

Mirroring Task 876's canonical pointer: the route writes the override when the source is either
the deterministic per-sender id **or** the current override value. Re-seating a fork moves the
pointer forward instead of stranding it.

### Why not a mutable binding

Rejected, per the task. `adminSessionIdFor`'s determinism is what lets an inbound resume the right
JSONL after a bridge or manager restart, and every existing channel session's id derives from it.

### Relationship to Task 1746

`spawn-request.ts` currently comments that "the deterministic per-sender id is the ONLY id a
sender's session ever has", written when Task 1746 removed Task 1526's per-firing seat. That seat
was **salted per scheduled firing**: it minted a new session on every dispatch, which then
attached under the sender's own hub key and swallowed their real messages, splitting one
conversation across two sessions.

This override is a **stable forward pointer**: one session, repointed once by an operator action,
resolving identically for every subsequent inbound. It cannot split a conversation because it
never yields a second id for a sender. Both comments are rewritten to say the deterministic id is
the default rather than the only id, and to record this distinction so the next reader does not
re-remove the override.

### Unit 4 — observability

- `op=request` gains the source's resolved channel and senderId, so the lifeline says what kind of
  row was re-seated.
- `op=channel-forward senderId=… from=… to=…` at the override write, mirroring the existing
  `op=canonical-forward`.
- `op=channel-fork-unbound senderId=… session=…` at a channel fork, naming the window in which the
  fork has no reply tool. The brief did not ask for this; it exists because the manager's own
  binding-lost invariant provably cannot fire on this path.
- **Standing check.** The WhatsApp gateway records a detach timestamp per sender when
  `op=channel-detached` fires, and clears it on attach. `runDuplicateSenderAudit` queries that map
  and emits `op=sender-unseated senderId=… since=…` for any sender still detached longer than one
  sweep interval (15 min). This reconciles expected state — every known sender has a seat or a
  reason not to — against actual, without reproducing the failure. A detached sender with no live
  channel server is otherwise invisible to action logging, because nothing happens.

### Comments to correct

`session-reseat.ts`'s header (asserts the fork is always webchat-bound),
`SessionReseatControl.tsx:10-13` (asserts the opposite), and both `spawn-request.ts` Task 1746
comments. All four must describe the delivered behaviour.

## Testing

- `session-reseat.test.ts` — a `whatsapp` admin sidecar source produces a payload carrying
  `channel:'whatsapp'`, the source `senderId`, and no `webchatChannel` key; a `telegram` admin
  sidecar does the same for its channel; a `webchat` sidecar produces today's payload
  byte-for-byte; a `whatsapp` sidecar with `role:'public'` produces today's payload; a sidecar with
  no `accountId` omits `targetAccountId`.
- Override round-trip — write then read returns the fork id for that sender and null for an
  unwritten sender; a second sender's write preserves the first; a malformed file reads null
  rather than throwing.
- Spawn builders — with an `overrideSessionId` the request carries it; without one it carries
  `adminSessionIdFor`. Both channels.
- Unseated audit — a sender detached longer than one interval is reported; one detached within the
  interval is not; one that re-attached is not.
- Regression proof on-device — re-seat a WhatsApp row, confirm no `channel-detached` without a
  following `channel-attached`, then send an inbound and confirm `op=inbound … sessionId=<fork>`.

## Files

| File | Change |
|---|---|
| `platform/ui/server/channel-session-override.ts` | new |
| `platform/ui/server/routes/admin/session-reseat.ts` | sidecar read, channel payload, override write, lifeline fields |
| `platform/ui/app/lib/whatsapp/gateway/spawn-request.ts` | optional `overrideSessionId` |
| `platform/ui/app/lib/telegram/gateway/spawn-request.ts` | optional `overrideSessionId` |
| `platform/ui/server/index.ts` | override read at both spawn sites; unseated audit |
| `platform/ui/app/lib/whatsapp/gateway/wa-gateway.ts` | detach-time map; override in `op=inbound` |
| `platform/ui/app/lib/telegram/gateway/telegram-gateway.ts` | override in `op=inbound` |
| `platform/ui/app/lib/whatsapp/gateway/duplicate-sender-audit.ts` | `op=sender-unseated` |
| `platform/ui/app/components/SessionReseatControl.tsx` | comment correction |
| `.docs/admin-webchat-native-channel.md` | document the override and the unbound window |
| `.tasks/LANES.md` | archive sync |
