# 18 — Origin guard (страж канала-источника)

**Русский** · [English](../18-origin-guard.md)

Когда человек пишет агенту из одного канала (скажем, из web-консоли), а агент отвечает в другой (скажем, в Telegram), ответ уходит туда, куда человек не смотрит. До сих пор этот класс ошибок держался только на доктрине агента («отвечай туда, откуда пришло») — на памяти, не на механике. **Origin guard** закрепляет правило на роутере: *ответ* агента человеку обязан целиться в канал, из которого человек писал в последний раз, иначе он **удерживается** до явного подтверждения одной командой — вместо доставки.

Это **нормативный контракт** поведения стража, его состояния и CLI-поверхности.

## Что считается ответом

Страж ведёт одну запись на пару **(человек, агент)** — люди суть пиры с `intelligence: natural`:

- успешная доставка **человек → агент** штампует пару: канал человека (`rt` = рантайм отправителя) и момент (`inboundTs`);
- успешная доставка **агент → человек** штампует `answeredTs`.

Пара **взведена**, когда последнее входящее *не отвечено* (`inboundTs > answeredTs`) и моложе TTL взвода (по умолчанию 48 ч). Отправка агента человеку **при взведённой паре презюмируется ответом** и обязана целиться в канал-источник. После ответа — либо после того как TTL отсёк залежалую нить — любая следующая отправка есть *инициатива* и проходит без трения.

Почему не простое окно времени: долгая задача — ровно тот ценный класс, который нужно ловить (спросили из web, агент закончил через два часа — ответ всё равно должен лечь в web), а длинное окно ложно удерживало бы инициативу после уже отвеченного вопроса. Неотвеченность — семантически точный дискриминатор; TTL лишь ограничивает залежалость (нить, закрытая мимо роутера — в приаттаченном терминале, голосом — стражу не видна и не должна держать пару взведённой вечно).

## Удержание

При взведённом несовпадении — **намеченный** канал (аргумент `runtime`, а при его отсутствии дефолтный рантайм человека) отличается от взведённого источника — отправка **не доставляется**. Она сохраняется дословно как *удержанная* (pending), а отправитель получает инструктивную ошибку:

```text
origin-guard: "arthur" last wrote to you from "web" (2026-07-15T12:58:03+03:00) and that
message is not yet answered — this send targets "telegram". Message NOT delivered; held
verbatim as og-1a2b3c4d (expires in 15 min).
- cross-channel is intentional → deliver as addressed: iapeer confirm-send og-1a2b3c4d
- reply to the origin channel instead: iapeer confirm-send og-1a2b3c4d --runtime web
Do not re-send the message text — confirm-send delivers the held message.
```

Оба выхода стоят одну shell-команду и **ноль регенерации сообщения**: удержанная отправка несёт исходные текст/топик/рантайм и принадлежащие получателю копии каждого вложения. Копирование идёт до сохранения hold, поэтому удаление временных исходников в 15-минутном окне решения не ломает `confirm-send`.

```bash
iapeer confirm-send og-1a2b3c4d                 # доставить как адресовано (кросс-канал намеренный)
iapeer confirm-send og-1a2b3c4d --runtime web   # доставить то же сообщение в канал-источник
```

`confirm-send` отправляет от **исходной идентичности отправителя**. Сессия пира (несущая `PEER_IDENTITY`) может подтверждать только свои удержанные отправки; голый операторский шелл — любые (домен доверия same-uid). Захват атомарен — из двух конкурентных подтверждений побеждает ровно одно. Провал доставки возвращает удержание на место (повтор до TTL); истёкшее отвечает «отправь сообщение заново».

## Где это работает

Страж и оба штампа живут в `routeSend` — единственной точке маршрутизации, общей для **обоих** путей входа: инструмента `send_to_peer` демона *и* внутрипроцессного CLI `iapeer send` (которым мосты каналов доставляют входящие от человека). Страж только на стороне демона никогда не видел бы входящую половину и никогда бы не взводился. Обход (bypass), которым пользуется `confirm-send`, — зависимость маршрутизации, которую прошивают точки входа; он **недостижим из tool-аргументов агента**, подделать его агент не может.

Состояние — на диске (обязано переживать рестарты демона и короткоживущие CLI-процессы), под корнем состояния:

```text
~/.iapeer/state/iapeer/origin/
  state.json          # (агент → человек → {rt, inboundTs, answeredTs}) — last-wins, атомарные записи
  pending/<id>.json   # удержанные отправки; вложения уже указывают на копии inbox
```

## Границы и невмешательство

Страж касается ровно одного класса трафика: отправки **агент → человек** при взведённой паре. Всё прочее не затронуто по построению: агент→агент, человек→агент, сервисные (`absent`) отправители и цели, ответы в тот же канал (источник = намеченный канал), инициатива (пара разряжена) и любые потоки при выключенном страже. Ввод, набранный прямо в приаттаченном терминале, роутер не проходит — он не взводит и не разряжает пару; итоговую залежалость ограничивает TTL взвода. Жёсткие политики доставки (например, telegram-политика лица отправителя) продолжают действовать в момент доставки, включая путь подтверждения.

Исходы доставки наблюдаемы в `delivery.log`: удержание — строка `ok=false`, чей `err` несёт ноут стража (grep `origin-guard`); подтверждённая доставка несёт `og=<id>`, связывающий её с прежним отказом.

## Ручки

| Env | По умолчанию | Смысл |
|---|---|---|
| `IAPEER_ORIGIN_GUARD` | вкл | `0` выключает стража и штампы на всём хосте |
| `IAPEER_ORIGIN_GUARD_ARM_TTL_MS` | 48 ч | сколько неотвеченное входящее держит пару взведённой |
| `IAPEER_ORIGIN_GUARD_PENDING_TTL_MS` | 15 мин | сколько удержанная отправка остаётся подтверждаемой |
