ADR-0064: Milestone 14 inbound surfaces and pairing¶
- Status: Proposed; amended 2026-09-28 (decision 13 accepted by the owner)
- Date: 2026-08-20
- Related: Sections 16, 21, 22, 27.5, and 29 of the engineering plan; ADR-0004, ADR-0011, ADR-0017, ADR-0034, ADR-0049, ADR-0058, ADR-0059, ADR-0061, ADR-0062
- Detailed design:
docs/plan/inbound-surfaces.md
Context¶
Section 29.4 names Surfaces — inbound messaging channels — as devices with an empty capability set unified under one session-key resolver, and requires an unknown sender to be default-denied until an explicit pairing step completes. ADR-0017 decided the pairing shape; the seam audit (ADR-0034) found that the session-key resolver is the one genuinely new mechanism in Section 29, that pairing needs a home and an endpoint, that no client is attributed on a write, and that the corpus had no rule for the trust label of a paired third party. Milestone 12 (ADR-0062) landed the device registry and the notification outbox the surface reuses.
The owner authorized Milestone 14 on 2026-08-20 (ADR-0061) as the third roadmap milestone, with a Telegram bot as the first channel, so the agent can be reached from a phone without the native client.
Proposed decisions¶
- A Surface is a Device with an empty capability set. One
devicesrow per configured bot withkind = surface,platform = telegram, andpush_provider = telegram— the kind and provider Milestone 12's closed enums declare for this purpose — so the row is representable by the Milestone 12 contract; no second model, as ADR-0034 decided. - Long polling, not a webhook. The API stays loopback-only behind the proxy; the surface role polls, resumes from the last committed update, and holds a per-surface advisory lock so two workers are safe. A webhook is a second implementation of the same port if ever needed.
- A dedicated least-privilege
surfacerole holds the bot token. The token comes from an owner-only private file into a secret field, never the credential map; the role has the database credential and nothing else; it runs Milestone 12's dispatcher for the Telegram provider only (dispatch is partitioned by provider) and drains the surface-reply outbox, so the token lives in one process and the push key in another. - Pairing as ADR-0017 decided, given a home. A one-time code minted by an
authenticated principal with
surface.write, at least forty bits, salted hash, ten-minute expiry, five attempts, one-hour per-sender lockout, returned once, bound to the surface and sender withgranted_scopesno wider than the minter's; revocation effective before the next message. - The session-key resolver. One live mapping per
(surface, external_key);dm:<chat_id>built, group and thread shapes reserved; rotation on/new, idle time, a closed session, or a stale pinned agent version; a rotated key is never reused. - A paired message is an ordinary submission through one shared function. The HTTP submit body is extracted and shared with the surface ingress and the CLI; no second path. A busy session rejects rather than queues.
- One ingress transaction per update, receipt first. The receipt keyed by
(surface, update_id)is the idempotency boundary; the poll offset advances only from committed receipts; rejection of an unpaired, locked, or rate-limited sender happens before any content write. - A paired sender's message is
USERfor the bound principal. Pairing is authentication; the owner pairing their own account is the case this milestone serves. Pairing a third party to the owner's principal is a widening that needs explicit owner approval and is bounded bygranted_scopes; a dedicated third-party label is roadmap item B3. - Attribution on the write. Origin on the seed message and the queued run, the receipt as the reverse map, the surface in session metadata; no session or run column, because a session is not owned by a channel.
- Notifications through the Milestone 12 outbox; replies through a
separate surface-reply outbox. A reply is not a notification and never
enters Milestone 12's closed trigger catalog; both are redacted with the
secret-rule families and chunked to Telegram's limit with per-chunk
progress; approvals by
/approveand/denythrough the existing service; questions by a plain reply through the existing input rule; inline keyboards deferred. Because notification payloads are content-free, the surface role reads question text and approval summaries through the existing application services as the paired principal under the pairing's scopes, and sends a generic notice when it lacks them. - Two scopes, six routes, two flags, default-off.
surface.read,surface.write; pairing routes and CLI;AGENT_SURFACE_API_ENABLEDandAGENT_SURFACE_WORKER_ENABLEDchanging together. - One gate area,
surface, twenty-one gates.
Consequences¶
- The owner can message the agent from a phone and receive replies, approvals, and questions there; an unknown sender reaches nothing.
- Six tables, two scopes, six routes, one role, one unit, one environment
file, a
surfaces:limits block, atelegram_bot_tokensecret-rule family, and one transport adapter are added; the public run submission body becomes a shared application function. The Milestone 12 outbox and its trigger catalog are unchanged. - The owner must create the bot and place its token in a private file on the host.
Alternatives considered¶
- Webhook delivery: rejected for this deployment; public ingress into the API process or a third virtual host, coupled to API availability.
- Routing inbound messages through the API with a stored bearer token: rejected; it turns a worker into a credential holder and bypasses the pairing boundary.
- A new trust level for paired senders: deferred; the only sender in this
milestone is the principal, and
granted_scopesis the right lever for anyone else. - Origin columns on sessions and runs: rejected; a session may be continued from another client, so attribution belongs on the write.
- Inline-keyboard approvals: deferred; a second resolution entry point for no added authority.
Amendment — 2026-09-28¶
On PostgreSQL every paired message was refused (REJECTED_ADMISSION,
surface.cost_reservation_missing): admission reserved the default agent's
cost limit, and the shipped agent has none. The in-memory admission controller
allows everything, so no gate saw it. The owner chose a surface budget over a
cost limit on the default agent, which would have capped every chat, reversed
ADR-0130's "every other chat has no cost limit", and changed the
content-addressed agent version.
- A surface run has its own budget. (Owner, 2026-09-28.) The
surfaces:limits block gainsmax_cost_per_run(USD 10) andsynthesis_reserve_cost(USD 2). Admission reserves the budget and the run a paired message starts carries it as its cost limit and synthesis reserve, unless its agent's own limit is lower. The reservation therefore bounds what the run can spend. With the USD 25 daily ceiling, two surface runs can be in flight at once. Chats started from any other client keep the ordinary limits. The ingress requires the budget and the self-approval setting as arguments, so neither can be left unwired again.
Engineering decisions made while resolving the review findings pull request 104 left open:
- An unreadable update is receipted, not retried. A Telegram update with
an identifier but no readable sender, or one that is not a message,
receives an
IGNORED_UNREADABLEreceipt (migrationc978eb0bac0f), so the poll offset passes it rather than stalling on it. - Replies have a terminal attempt limit. A reply still undelivered after
reply_max_attempts(eight) claims settles as failed. - A repeated pairing key is a non-retryable conflict. Replaying the response would present the code twice, which decision 4 forbids. The 409 names the issued code's identifier and expiry.
- Credentials stay with their role. The surface and call loaders refuse
a provider credential variable they do not load, and every other role
refuses a surface secret-file variable, instead of ignoring it. The call
worker keeps its own
BLAND_API_KEY_FILE, which ingress refuses as release preflight already did. The call roles began refusing on 2026-09-29, once production's two call environment files were confirmed to hold none of these variables. - The surface role's database credential is its own login. (2026-09-29.)
Decision 3's credential is
veetbot_surface, a NOINHERIT, NOBYPASSRLS login, not the application's table-owning login. It holds only the table privileges the surface paths use, found by running every path under a probe login and then removing each grant in turn. The release validates that exact list, as it does for the scheduler's login, whenever the surface worker is enabled. Unlike the scheduler, the role submits to conversations that already exist, so it holdsUPDATEoneventsfor the row lock that serializes session-history projection with People erasure.
Consequences: one limits key pair, one reply limit, one receipt disposition
and migration, and one reason code (surface.unavailable, a pairing attempt
on a revoked surface). surface.cost_reservation_missing no longer exists.