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_querywith thecallback_datastring. - Handler routes it, then
answerCallbackQueryclears the button spinner. - Load-bearing lesson (YACT has a comment about it):
callback_datais 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_updatesgains"callback_query";telegramUpdategains aCallbackQueryfield;Run/handlebranch on update type.
Phasing (one PR each; vertical slice completes at B3)¶
- B1 —
pending_actionstable + 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_queryingest handling + re-auth + execute forcreate_task. Table-driven authz tests + a callback-dispatch test. - B3 —
create_taskpropose 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; neververified).
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_detailshows the new task; tap Cancel → nothing created.
Decisions to confirm before B2/B3¶
- Proposal trigger — agent proposes only on an explicit ask ("file/create a task"), not proactively. (Recommend: explicit only, to avoid button spam.)
create_taskrequired 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.)- Expiry — 1h before a Confirm button goes stale. (Recommend 1h.)
- Scope of
set_lifecycle— allow all of backlog/ready/in_progress/blocked/cancelled, neververified. (Recommend yes.)