Skip to content

Spec: Chat Delivery Surface (Teams & Slack)

File: .ai/specs/app-layer/2026-07-04-chat-delivery-surface.md Status: Approved (security review 2026-07-04 by J. Porter — nonce lifecycle, hook verification ordering, and trust-chain conditions incorporated in v0.4; OQ stances ratified) Owners: app-layer lane; reviewers: integration lane (clarification contract, identity), security review (callback verification, leakage) Related: temporal-clarification spec (this is its delivery substrate, PR (3)), entra-identity-connector spec (alias binding), job-queue-leases spec (clarify: leases), PR-042 audit sink.


1. Overview

Defines the app-layer service that is Telha's single chat presence in Microsoft Teams and Slack: it delivers Telha-initiated messages (clarification question cards, the primary v1 consumer, plus steward digests and alerts), receives interactive callbacks, and owns the routing rule that keeps the two chat directions from ever being confused: "Telha Asks Back" (outbound clarifications, a write path) and "Ask Telha" (inbound Q&A, a read path) share one bot identity but are disjoint at the protocol level. This spec owns the transport-level guarantees: where cards may appear, how callbacks are authenticated, how a chat identity binds to a PERSON, how double-sends are prevented, how per-tenant credentials keep delivery inside tenant boundaries, and how message intent is discriminated. What clarification questions say and how answers bind is owned by the temporal-clarification spec; the inbound Q&A engine is owned by the ai-assistant work (PR-042) and future ask-in-chat PRs; this spec is the shared pipe, made safe.

2. Problem Statement

Without a defined delivery surface: (a) clarification cards containing confidential span text can land in shared channels, turning the loop into an RBAC bypass; (b) unverified webhook callbacks let anyone who discovers the endpoint submit "answers"; (c) a Slack display name gets treated as the responder identity, so binding evidence rests on a self-chosen username; (d) at-least-once job delivery double-sends cards on re-lease, producing duplicate answers and user distrust; (e) a bot credential shared across tenants can deliver tenant A's question to tenant B's workspace; (f) without a hard protocol boundary between outbound clarifications and inbound Q&A, a free-text reply ("it was the 12th, and by the way what does clause 12.4 say?") could be parsed as a binding clarification answer, turning casual conversation into an evidence write; conversely, a user asking a question could receive it back as if Telha were asking them.

3. Proposed Solution

Normative decisions:

  1. Clarification cards are delivered to 1:1 conversations only (Teams personal chat, Slack DM). Posting question content to channels or group chats is prohibited; digests and steward alerts are also DM-only.
  2. Every inbound callback is authenticated before processing: Teams via Bot Framework JWT validation (issuer, audience, signature), Slack via signing-secret verification with a ±5 minute timestamp window. Unauthenticated callbacks are dropped and audited, never processed.
  3. Responder identity comes from verified aliases, never from display fields: Teams from.aadObjectId → PERSON via aad_oid; Slack user.id → PERSON via slack_user_id (attached only through the Entra connector's verified-email match). A callback from an unmapped identity is recorded as a non-binding attestation with reason RESPONDER_NOT_AUTHORISED; it can never bind.
  4. Sends are idempotent: before sending, the adapter consults the clarification's recorded message_refs; a re-leased job with an existing live card for the same (clarification, person) MUST NOT send again.
  5. Credentials are per Telha tenant (Teams app registration, Slack app/bot token). Cross-tenant delivery is structurally impossible: the adapter resolves credentials from the job's tenant scope, and a missing credential set is a skip (NO_DELIVERY_ALIAS family), not a fallback to another tenant's bot.
  6. Card content is minimised: at most card_span_max_chars of span excerpt, no file attachments, no links that bypass authentication. The full document is referenced by a deep link into the source system, where the source's own ACLs apply.
  7. One bot identity per tenant serves all Telha chat interactions. Users see a single "Telha" app; separate bots for asking and asking-back are prohibited (install friction, split identity, doubled credentials).
  8. Clarification answers are accepted ONLY as card actions whose payload carries the discriminator {kind: "clarify_answer", clarification_id, ...} ("Something else…" is a card input submission and still carries it). Free-text messages MUST NOT be interpreted as clarification answers under any circumstances; there is no NLP fallback. Chat's only write path is a discriminated card action.
  9. Conversational (non-action) messages route to the ask handler, which is read-scoped: the asker's bound PERSON maps to their Telha role and answers are filtered by the same RBAC as the web chat. Until ask-in-chat ships, the ask handler replies with a redirect to the available ask surfaces and never falls through to clarification handling. Card actions with an unknown or missing kind are dropped and audited, never guessed.

4. Architecture

clarify-bot service (app-layer package, token audience "clarify")
  PollJobs kinds=["clarify:"] ─▶ lease ─▶ eligibility + RBAC gate (clarification spec)
        │ resolve PERSON ─▶ delivery alias ─▶ tenant credential set
  ChannelAdapter interface
    ├─ TeamsAdapter: Bot Framework REST, proactive 1:1 conversation,
    │    Adaptive Card v1.5, invoke-activity callbacks at /hooks/teams
    └─ SlackAdapter: chat.postMessage (DM), Block Kit,
         interactivity callbacks at /hooks/slack
        │  callbacks: verify ─▶ bind identity ─▶ intent router
        ▼                                          │
  intent router (normative §3.8/§3.9)              │
    ├─ card action {kind: "clarify_answer"} ─▶ POST /v1/clarifications/:id/answer
    ├─ card action, unknown kind ───────────▶ drop + audit
    └─ conversational message ──────────────▶ ask handler
         v1: redirect reply ("ask me in the Telha app / web chat")
         future: ai-assistant runChat over MCP, read-scoped to the asker
  outcome card update (recorded / quorum pending / conflicted per clarification spec §7)
  audit events for send, skip, callback, verification failure, dropped actions

5. Data Models

No new core state. The CLARIFICATION node (temporal-clarification spec §5) records asked[], channel, and message_ref per delivery and per response; this spec defines message_ref formats: Teams {conversation_id, activity_id}, Slack {channel_id, ts}, CLI "cli". Tenant credential sets live in app-layer configuration (env-referenced secrets), keyed by Telha tenant id: {teams: {app_id, app_password_env, service_url?}, slack: {bot_token_env, signing_secret_env, team_id}}. Delivery attempts and skips are audit rows (PR-042 sink), not graph state.

6. API Contracts

ChannelAdapter (internal interface). send_card(person, tenant, card) -> message_ref | DeliveryError, update_card(message_ref, card), verify_callback(request) -> VerifiedCallback | Reject, bind_identity(callback, tenant) -> personId | Unmapped. DeliveryError maps onto clarification skip codes: user not reachable → NO_DELIVERY_ALIAS; API failure after retries → DELIVERY_FAILED; workspace mismatch → TENANT_SCOPE_MISMATCH.

Inbound endpoints. POST /hooks/teams (Bot Framework activities; verify JWT; only invoke/message activities from 1:1 conversations are processed) and POST /hooks/slack (interactivity payloads; verify signature+timestamp; respond within Slack's 3s ack window, then process async). Verification ordering + rate limiting (approval condition, 2026-07-04): /hooks/* are public internet endpoints and MUST verify the platform signature/token before accepting or processing anything. Slack: verify FIRST, then ack within the 3s window, then process asynchronously — an invalid signature is rejected and never acknowledged as a successful action. Teams: verify, then process or enqueue. A basic per-tenant and per-IP rate limit applies to /hooks/* before any expensive processing; rate-limit events and verification failures are emitted to the audit/security sink. Both endpoints are tenant-routed by credential match, never by caller-supplied tenant ids. Action payload schema (normative): every card button/input submission carries {kind, clarification_id, answer_index? | answer_custom?, nonce}; kind is the intent discriminator (clarify_answer is the only v1 write kind; future kinds are additive). The nonce ties the action to the delivered card, so a payload replayed against a different clarification is rejected. Nonce lifecycle (approval condition, 2026-07-04): the nonce is generated before delivery, persisted with the delivery record and message_ref BEFORE the send, and reused on idempotent re-send of the same card (a re-leased job re-delivers the same nonce, so §3.4 idempotency and nonce validation compose). It remains valid while the clarification accepts the action and is invalidated when the clarification reaches a terminal state for that action (bound and policy disallows further responses, expired, dead-lettered). The hook handler validates platform signature, tenant, message_ref, action id, and nonce before accepting any action. Replay of the same valid action is safe because answer recording is idempotent on (clarification_id, responder, answer); a nonce mismatch or a nonce presented against a different clarification is dropped and audited. On verified clarify_answer actions the service calls POST /v1/clarifications/:id/answer with the bound personId and message_ref; the API's outcome (bound / partial / conflict / attestation, per the clarification spec's matrix) drives the card update. Trust chain (approval condition, 2026-07-04): this service does not itself bind facts — it submits verified card actions using its "clarify"-audience service token, which is the ONLY credential class permitted to assert a third-party responder on the answer endpoint (normative rule and 403 RESPONDER_ASSERTION_FORBIDDEN defined in the temporal-clarification spec §6); all other callers bind as themselves. This pairing is what prevents forged clarification evidence. Conversational messages never reach the clarification endpoint by construction: the intent router has no path from message text to an answer call.

Failure behavior. Teams proactive delivery requires the app installed for the user: ConversationNotFound/403 → skip NO_DELIVERY_ALIAS with audit hint "app not installed"; Slack channel_not_found/user_not_found likewise. Rate limits: honor Retry-After, retry within the lease, then fail(retryable) to advance the ranking.

7. UI/UX

Card layouts implement the temporal-clarification spec §7 copy exactly (normal, critical/quorum, first-confirmation, satisfied, attestation, conflict), rendered as Adaptive Cards (Teams) and Block Kit (Slack) with: question sentence, span excerpt (capped), candidate buttons labeled with date + origin, "Something else…" input, and a footer naming the document and workspace. After any callback the original card is updated in place (no second message). Digest card: grouped list of pending questions with per-item buttons. Steward alert card: conflict/dead-letter notice with skip-reason list and a deep link to telha clarify show (CLI-first review surface). All copy avoids em-dashes per house copy rules.

8. Configuration

[clarify.channels]: order ("teams,slack": first resolvable alias wins). [clarify.channels.teams]: enabled, app_id, app_password_env. [clarify.channels.slack]: enabled, bot_token_env, signing_secret_env, team_id. card_span_max_chars (280). Deliberately NOT configurable: DM-only delivery, callback verification, the unmapped-identity attestation rule, idempotent send, per-tenant credential isolation.

9. Alternatives Considered

  • Channel/group posting for visibility. Rejected: leaks span text beyond the RBAC gate; visibility needs are served by digests to authorised individuals.
  • Incoming-webhook-only integration (no bot). Rejected: webhooks cannot receive button callbacks; the one-tap loop is the feature.
  • Trusting Slack profile email at callback time. Rejected: profile emails are user-editable in some workspace configurations; binding aliases attach only via the Entra connector's verified match.
  • Heavy SDK dependency (full botbuilder / bolt frameworks). Partially rejected: adapters use the vendors' minimal verification libraries plus raw REST; full frameworks bring server assumptions the app layer does not want. Revisit if maintenance cost says otherwise.
  • Email as a v1 channel. Deferred (consistent with the clarification spec): no one-tap semantics, weak identity binding.
  • Separate bots for "Ask Telha" and "Telha Asks Back". Rejected: two app installs per tenant, two identities to bind, doubled credential surface, and users still would not learn the distinction. The confusion risk is solved at the protocol layer (discriminated card actions, no free-text binding), not by splitting the presence.
  • NLP interpretation of free-text replies as clarification answers. Rejected: convenience is not worth an ambiguous write path into evidence. If a user replies in text instead of tapping, the bot re-prompts with the card; the tap is the contract.

10. Implementation Approach

Lands as the clarify-bot PR (PR-063, build doc Phase 3b; = temporal-clarification PR (3)): app-layer/packages/clarify-bot/ with the adapter interface, intent router, Teams adapter first, Slack adapter second, hooks endpoints, identity binding, card rendering, digest batching, audit wiring, and a FakeAdapter for tests (records sends, replays scripted callbacks). Depends on the Entra connector PR (aliases) and core clarification PR (endpoints, job kind). Crash behavior: send-then-crash before job completion re-leases and hits the idempotent-send check (§3.4). Ask-in-chat is explicitly out of scope for this PR and ships later as its own PR (PR-070): it wires the existing ai-assistant engine (PR-042 runChat over MCP, RBAC and audit already built for web chat) into the same ChannelAdapter and identity binding, replacing the v1 redirect reply. Nothing in this spec's surface changes when it lands; the intent router already reserves the message path for it.

11. Migration Path

Greenfield; ships disabled until per-tenant credentials are configured. Teams requires tenant-admin app deployment (documented runbook, part of the pilot checklist); Slack requires app install to the workspace. No data migration; enabling/disabling a channel only affects future deliveries. Forward compatibility: new channels (email, Google Chat) implement ChannelAdapter without core changes.

12. Success Metrics

  • chat_callback_verification: forged Teams JWT and bad Slack signature/stale timestamp are rejected, audited, and never reach the answer endpoint.
  • chat_dm_only: a card can never be addressed to a channel/group conversation (adapter-level assertion).
  • chat_identity_binding: Teams aadObjectId binds to the right PERSON; an unmapped Slack user's answer records as attestation with RESPONDER_NOT_AUTHORISED and binds nothing.
  • chat_idempotent_send: re-leased delivery job with an existing message_ref sends nothing new; exactly one live card per (clarification, person).
  • chat_tenant_isolation: tenant A's job can never be delivered through tenant B's credentials; missing credentials produce a skip, not a fallback.
  • chat_card_update: each answer outcome (bound / partial / conflict / attestation) updates the original card to the §7 copy, verified via FakeAdapter.
  • chat_no_freetext_binding: a free-text DM reply quoting a candidate date verbatim ("it was 12 June") creates no response record, triggers a re-prompt with the card, and is audited as a conversational message; the clarification is untouched.
  • chat_action_discriminator: a card action with a missing or unknown kind, or a nonce not matching the delivered card, is dropped and audited; a replayed clarify_answer payload against a different clarification id is rejected.
  • chat_action_nonce_lifecycle: valid nonce accepted; missing/wrong nonce rejected; the nonce survives lease retry (idempotent resend delivers the same nonce); an action against a terminal clarification is rejected; replay of the same valid action does not double-write.
  • chat_hook_verify_before_ack: invalid Slack signature and invalid Teams token are rejected and audited without reaching answer processing; a valid Slack request is verified before the ack and processed async; a rate-limited request never reaches answer processing.

13. Open Questions

  • OQ-1: Slack Enterprise Grid routing? Ratified 2026-07-04: single workspace per tenant in v1; Grid support waits for real customer topology.
  • OQ-2: Teams proactive-install friction? Ratified 2026-07-04: org-wide app deployment in the pilot runbook; the skip path degrades gracefully where not installed.
  • OQ-3: Localisation of card copy? Ratified 2026-07-04: English-only v1; copy strings centralised so translation is additive.
  • OQ-4: When does ask-in-chat ship? Ratified 2026-07-04: after the clarify-bot proves the transport and the Phase-3 trace surface gives chat answers evidence links (PR-070); reuses adapter, identity binding, and intent router unchanged.

14. Changelog

  • 2026-07-06 — v0.7: as-built record for the Slack Block Kit pass and the grounded ask answer card (status stays Approved). Three changes to this surface, all additive and offline-tested (clarify-bot suite 77 green): (1) §7 Slack cards rebuilt to idiomatic Block Kit via a typed builder (clarify-bot/src/slack_blocks.ts) with a validateMessageBlocks guard: header, section fields, dividers, styled buttons with confirm dialogs on critical clarifications, context icons. This FIXED a latent bug — the question card emitted an input block in a chat.postMessage, which Slack rejects (invalid_blocks); input blocks are modal/Home-tab only. (2) Slack custom-answer ("Something else…") now uses a modal (views.open → datepicker → view_submission), because a message cannot host an input block. The modal's private_metadata carries {clarificationId, nonce, messageRef} so the submission validates against the same delivery record (nonce/message_ref/tenant) the answer path already checks; the verify→ack→async hook ordering is unchanged (new view_submission runs behind the same signature gate). The Teams custom-answer path remains broken (structured answerCustom vs answerCustom:true) and is tracked separately. (3) §3.9 read path: a grounded answer is delivered as an evidence card, not plain text. When the ask run grounds the answer with the generate tool, the handler fetches getTrace and returns a verified result (answer + top cited evidence with per-claim verdicts + {supported, partial, unsupported} counts + traceId); hooks posts it as a card into the same DM via the additive replyCard adapter method, with an "Open full trace" deep link to ${appBaseUrl}/trace/:id (new appBaseUrl config / TELHA_APP_URL; the web page is mercato /trace/[id]). A role without the generate tool, an error, or a trace-fetch failure degrades to the PR-070 text reply. RBAC reach (recorded decision needed): generate/getTrace are admin-only tools (ai-assistant/rbac.ts PHASE3), and clarify-bot maps only steward → admin, so today ONLY stewards receive the grounded card; viewers/editors get a text answer. Widening the answer card to editors/viewers requires moving generate/getTrace in the tool allowlist (a deny-by-default §4 change) — deferred to product/security sign-off. Person-scoped evidence exclusion (the "access decision" note) is not yet enforced by the pipeline; the card slot exists but is left unset so it never claims a false exclusion.
  • 2026-07-05 — v0.6: as-built record for PR-070 (ask-in-chat) — the §3.9 read path is live; the v1 redirect reply is replaced when ask.enabled (default false; disabled = exact PR-063 behavior, zero regression). src/ask.ts: AskHandler seam + AiAskHandler running ai-assistant's runChat (PR-042 engine, MCP session per run) with the asker's role — reply = the final turn's text plus a "checked: " suffix when tools ran, clipped to 4000 chars; errors map to a friendly reply + ask_error audit. Intent router: identity binding FIRST (shared with the answer path); unmapped/ambiguous identities get a link-your-identity reply + responder_not_authorised {path: "ask"} audit and never reach the handler (asking is read-scoped and still requires a bound PERSON); role = config map, viewer default. Rights→ChatRole mapping: steward → admin (viewer/editor 1:1) — RBAC enforcement itself stays ai-assistant's. The clarification write-path invariant holds WITH ask on: free text has no path to the answer endpoint by construction (the handler holds no answer surface); chat_no_freetext_binding re-pinned in ask-on mode + ask_never_touches_clarifications. Config ask.{enabled, mcpUrl, tokenKeyEnv, anthropicApiKeyEnv, model} (defaults TELHA_MCP_URL / TELHA_GRPC_TOKEN_KEY; token key falls back to the clarify key; the Anthropic key is named by env var and, with model, overrides the provider default). Enablement is deployment-global rather than per-tenant: the role map was already global in PR-063 config; a missing or disabled block is a graceful redirect. Reply delivery: one ADDITIVE adapter method replyText(message_ref, text) (Teams message activity into the callback's conversation; Slack chat.postMessage into the DM channel; every PR-063 method and the nonce/idempotency surface untouched), wired as the hooks sendReply transport (adapterSendReply) for redirect AND ask replies, so the answer lands in the SAME DM the question came from. Slack ordering holds: verify, ack, then the LLM runs entirely after the ack; an undeliverable reply audits reply_failed and never crashes the hook loop. The "checked:" suffix names only tools that actually ran (an RBAC-denied call is not presented as checked), and ask audit rows carry NO question/answer body text. 15 new tests (ask_in_chat suite: routing, disabled-off regression, unmapped identity, viewer default, never-binds, error fallback, tenant scope, same-DM reply after ack, reply-failure audit, RBAC denial with the backend untouched, reply cap, role prompt plumb, rights mapping); clarify-bot suite 59 green, ai-assistant untouched at 21.
  • 2026-07-05 — v0.5: as-built record for PR-063 (status stays Approved). app-layer/packages/clarify-bot/ (15 modules + 13 offline test suites, 44 tests — every §12 name plus the clarification spec's clarify_rbac_gate/clarify_escalation/clarify_caps delivery legs). Deviations and refinements: (1) Delivery protocol (lease/no-submit). The bot never calls SubmitIngestionResult — the clarify:temporal job IS the delivery. After sending (or digesting, idempotent no-op, terminal-state card refresh, or steward escalation) it stops heartbeating; the lease expires and the reaper re-pends with attempts+1 behind the per-kind backoff (base = escalation window) — that IS §4's "unanswered past the window advances the ranking". JobPayload gained an additive attempts field (proto field 7; grpc-contracts) so candidate selection is crash-safe: attempt N asks ranking[N]; skips walk FORWARD within one lease (a skipped candidate never burns an escalation window). Exactly one candidate gets a new card per lease. (2) Core serves the delivery view: new GET /v1/clarifications/:id with spanExcerpt (server-capped 500, bot-capped again to card_span_max_chars), aclJson, sourceId/ sourceName, and askedAliases (delivery aliases per ranked candidate, resolved server-side — logical ids are not property-queryable and the clarify audience holds no record-read credential). The steward is not a ranked candidate: its aliases come from bot config (stewardAliases). (3) ACL floor check resolves acl principals to person ids by the pinned uuid5 rules (aad_oid → person:, email → person-email:); unknown principal kinds are ignored; an unparseable aclJson fails CLOSED to NO_READ_PERMISSION. Floor-only in both directions. (4) Unmapped callback identity submits NOTHING: there is no responder to name, so the bot audits RESPONDER_NOT_AUTHORISED and updates the card ("could not be verified against a workspace identity") without touching the answer endpoint — strictly stronger than §3.3's "recorded as a non-binding attestation" (an attestation row would need a fabricated responder id; revisit if evidence value demands it). (5) Hooks: raw node:http (no framework); Slack HMAC ±300s constant-time over the raw body, verify → ack → async processing; Teams JWT via jose (^6, workspace-resolved) behind an injectable verifier; per-IP rate limiting precedes everything, then the side-effect-free team_id parse that selects the per-tenant signing secret, then verification. Tenant config entries carry organizationId (callbacks don't; the answer-side token scope needs it). (6) Nonce lifecycle as pinned: {nonce, state: "pending"} persisted BEFORE the send, message_ref attached after; a crashed pending record re-sends with its ORIGINAL nonce. Digest deliveries are stored sent with a digest flag; DIGESTED is the audit marker. DeliveryStore grew findByNonce/listForClarification beyond the sketch. New TS SDK surface: signServiceToken/AUDIENCE_CLARIFY (worker-shaped msgpack; cross-language vector pinned in core's token.rs).
  • 2026-07-04 — v0.4: Approved (security review by J. Porter). Three conditions incorporated: (1) nonce lifecycle pinned — persisted with message_ref before send, survives idempotent re-send, invalidated at terminal state, full validation chain listed; (2) hook verification ordering made explicit (Slack verify→ack→async; Teams verify→process) + per-tenant/per-IP rate limiting on /hooks/* with security-sink events; (3) answer-endpoint trust chain cross-referenced (only clarify-audience tokens assert third-party responders — temporal-clarification §6). Tests chat_action_nonce_lifecycle and chat_hook_verify_before_ack added. OQ-1..4 stances ratified.
  • 2026-07-04 — v0.3: PR numbers filled in from build doc v2.1 Phase 3b (PR-063 clarify-bot, PR-070 ask-in-chat); no design changes.
  • 2026-07-04 — v0.2: ask/asks-back disambiguation made normative. Single bot identity per tenant (§3.7); clarification answers only via discriminated card actions with nonces, free-text never binds and has no NLP fallback (§3.8, §6 payload schema); conversational messages route to a read-scoped ask handler, v1 = redirect reply, future = ai-assistant over the same adapter (§3.9, §10); intent router added to architecture; separate-bots and NLP-reply alternatives recorded as rejected; tests chat_no_freetext_binding and chat_action_discriminator added; OQ-4 (ask-in-chat timing) opened.
  • 2026-07-04 — v0.1: initial draft for peer review.