Skip to content

Chat confirmation dialogs + Tier 2 write actions

Goal: Let the chat agent perform board mutations (create a task, change a task's lifecycle) from Telegram, gated by an inline [Confirm] / [Cancel] button — modeled on YACT's pattern. The agent proposes; you tap; the hub executes deterministically. The agent never mutates directly.

Why this shape: matches the house rules — business logic in the backend, verified only by the checker, confirm-before-acting, no silent failure. The LLM is kept out of the actual mutation: it can only stage a proposal, which a deterministic handler validates and executes on your explicit tap.

YACT pattern we're porting (lib/yact/messaging/adapter/telegram.ex + router.ex)

  • Outbound message carries buttons (rows of {label, callback_data}) → Telegram inline keyboard.
  • A tap sends a callback_query with the callback_data string.
  • Handler routes it, then answerCallbackQuery clears the button spinner.
  • Load-bearing lesson (YACT has a comment about it): callback_data is unsigned and unscoped — a chat could craft one for another project's id. So the handler re-authorizes on tap before acting.

Architecture

callback_data is capped at 64 bytes — too small for a task title + why. So the proposal is staged server-side and the button only carries a short id.

DM: "file a task to add a /digest endpoint"
  -> agent calls create_task tool (in chat-mcp)
       -> INSERT pending_actions (chat_id, kind, payload jsonb, status=pending)   [stage only, no mutation]
       -> returns "proposed" to the agent
  -> worker sends the agent's reply + an inline keyboard:
       [Confirm]  callback_data = act:confirm:<pending_id>
       [Cancel]   callback_data = act:cancel:<pending_id>
  -> you tap Confirm
  -> HUB getUpdates receives callback_query -> handler:
       1. look up pending_actions[<id>]
       2. RE-AUTHORIZE: row.chat_id == callback.chat_id, status==pending, not expired,
          and (payload.project, if any) is within the chat's allowed scope
       3. execute deterministically via the in-process Items store
          (CreateTask / SetLifecycle — already built + tested)
       4. mark row done; answerCallbackQuery; edit message -> "Created ARGUS-NNN"

Three processes touch the shared pending_actions table, each with one job: - chat-mcp tool — stages the row (never executes). - chat-worker — presents the buttons for newly-staged rows (it already sends the reply). - hub ingest — handles the tap, re-authorizes, executes in-process.

Schema (migration 0039)

CREATE TABLE pending_actions (
  id          bigserial PRIMARY KEY,
  chat_id     bigint      NOT NULL,
  kind        text        NOT NULL CHECK (kind IN ('create_task','set_lifecycle')),
  payload     jsonb       NOT NULL,                 -- {title,parent_id,why,type} or {id,lifecycle}
  status      text        NOT NULL DEFAULT 'pending'
                          CHECK (status IN ('pending','done','cancelled','expired')),
  result      text,                                  -- e.g. "ARGUS-118" after execute
  presented_at timestamptz,                          -- worker sets when it sends the buttons
  created_at  timestamptz NOT NULL DEFAULT now(),
  expires_at  timestamptz NOT NULL DEFAULT now() + interval '1 hour'
);
CREATE INDEX pending_actions_present_idx ON pending_actions(chat_id, created_at) WHERE presented_at IS NULL;

Re-authorization (the security gate)

The id in callback_data is our own bigserial, not a user-supplied target — so unlike YACT (which put target uuids in the data) spoofing is already hard. The handler still checks, in order: row exists → status=pending → not expired → row.chat_id == callback.chat_id (the tapping chat owns it) → if payload.project is set, it's within the channel's allowed project. Any failure → answerCallbackQuery("expired or not allowed"), no mutation. Tapping twice is idempotent (second tap sees status=done).

Telegram plumbing (new)

  • Telegram.SendMessageWithButtons(text, rows)sendMessage + reply_markup.inline_keyboard.
  • Telegram.AnswerCallbackQuery(id, text) — clears the spinner / shows a toast.
  • Telegram.EditMessageText(messageID, text) — replace the prompt with the outcome (drops the buttons).
  • Ingest allowed_updates gains "callback_query"; telegramUpdate gains a CallbackQuery field; Run/handle branch on update type.

Phasing (one PR each; vertical slice completes at B3)

  • B1 — pending_actions table + store (ChatActions: Stage, ClaimUnpresented, MarkPresented, Get, Complete, Cancel). Dockertested. No outward behavior. (building now — foundation, reversible)
  • B2 — Telegram inline-keyboard + callback infra: the three Telegram methods + callback_query ingest handling + re-auth + execute for create_task. Table-driven authz tests + a callback-dispatch test.
  • B3 — create_task propose tool + worker presentation: the chat-mcp tool stages a row; the worker presents buttons after the reply. End-to-end live test: propose in DM → tap → task created. This is the demoable slice.
  • B4 — set_task_lifecycle: second action kind, reuses all the infra (move backlog→ready→in_progress→blocked; never verified).

Verification

  • Store: dockertest round-trips (stage → claim-unpresented → present → complete; expiry; double-confirm idempotency).
  • Authz: table-driven (wrong chat, expired, already-done, out-of-scope project → refused; happy path → executes).
  • Execute path: reuses the already-tested Items.CreateTask / Items.SetLifecycle.
  • Live: a real DM proposal → tap Confirm → task_detail shows the new task; tap Cancel → nothing created.

Decisions to confirm before B2/B3

  1. Proposal trigger — agent proposes only on an explicit ask ("file/create a task"), not proactively. (Recommend: explicit only, to avoid button spam.)
  2. create_task required fields — title required; parent optional (defaults to a chat-captures bucket or unparented). (Recommend: title required, parent optional — the agent suggests a parent, you can confirm as-is.)
  3. Expiry — 1h before a Confirm button goes stale. (Recommend 1h.)
  4. Scope of set_lifecycle — allow all of backlog/ready/in_progress/blocked/cancelled, never verified. (Recommend yes.)