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:
- 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.
- 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.
- Responder identity comes from verified aliases, never from display fields: Teams
from.aadObjectId→ PERSON viaaad_oid; Slackuser.id→ PERSON viaslack_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 reasonRESPONDER_NOT_AUTHORISED; it can never bind. - 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. - 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_ALIASfamily), not a fallback to another tenant's bot. - Card content is minimised: at most
card_span_max_charsof 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. - 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).
- 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. - 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
kindare 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: TeamsaadObjectIdbinds to the right PERSON; an unmapped Slack user's answer records as attestation withRESPONDER_NOT_AUTHORISEDand 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 unknownkind, or a nonce not matching the delivered card, is dropped and audited; a replayedclarify_answerpayload 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 avalidateMessageBlocksguard: header, section fields, dividers, styled buttons with confirm dialogs on critical clarifications, context icons. This FIXED a latent bug — the question card emitted aninputblock in achat.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'sprivate_metadatacarries{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 (newview_submissionruns behind the same signature gate). The Teams custom-answer path remains broken (structuredanswerCustomvsanswerCustom: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 thegeneratetool, the handler fetchesgetTraceand 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 additivereplyCardadapter method, with an "Open full trace" deep link to${appBaseUrl}/trace/:id(newappBaseUrlconfig /TELHA_APP_URL; the web page ismercato /trace/[id]). A role without thegeneratetool, an error, or a trace-fetch failure degrades to the PR-070 text reply. RBAC reach (recorded decision needed):generate/getTraceare admin-only tools (ai-assistant/rbac.tsPHASE3), and clarify-bot maps onlysteward → admin, so today ONLY stewards receive the grounded card; viewers/editors get a text answer. Widening the answer card to editors/viewers requires movinggenerate/getTracein 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:AskHandlerseam +AiAskHandlerrunning ai-assistant'srunChat(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_erroraudit. 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_bindingre-pinned in ask-on mode +ask_never_touches_clarifications. Configask.{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, withmodel, 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 methodreplyText(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 hookssendReplytransport (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 auditsreply_failedand 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_chatsuite: 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'sclarify_rbac_gate/clarify_escalation/clarify_capsdelivery legs). Deviations and refinements: (1) Delivery protocol (lease/no-submit). The bot never calls SubmitIngestionResult — theclarify:temporaljob 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".JobPayloadgained an additiveattemptsfield (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: newGET /v1/clarifications/:idwithspanExcerpt(server-capped 500, bot-capped again tocard_span_max_chars),aclJson,sourceId/sourceName, andaskedAliases(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 unparseableaclJsonfails CLOSED toNO_READ_PERMISSION. Floor-only in both directions. (4) Unmapped callback identity submits NOTHING: there is no responder to name, so the bot auditsRESPONDER_NOT_AUTHORISEDand 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 viajose(^6, workspace-resolved) behind an injectable verifier; per-IP rate limiting precedes everything, then the side-effect-freeteam_idparse that selects the per-tenant signing secret, then verification. Tenant config entries carryorganizationId(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 storedsentwith adigestflag;DIGESTEDis the audit marker. DeliveryStore grewfindByNonce/listForClarificationbeyond 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_lifecycleandchat_hook_verify_before_ackadded. 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_bindingandchat_action_discriminatoradded; OQ-4 (ask-in-chat timing) opened. - 2026-07-04 — v0.1: initial draft for peer review.