Repository navigation
Channels v2 (2/3): surfaces as transport, one outbound path, contacts policy - #1001
Conversation
… policy - Agent answers are lines first, then delivery rows; streamed answers are recorded SENT with the stream's id, never posted twice. Every adapter returns the platform id; late WhatsApp and Resend failures (bounce, complaint, failed) mark the delivery DEAD by id; send-then-crash is never resent. Redis wamid map removed. - contacts.contracts.outreach: may_send (suppression, handle, window), daily count once per line, opt-out first on every path, suppressions by hashed handle, START/typed-only opt-in, E.164 phones, host issuers, List-Unsubscribe one-click. Bounces and complaints suppress. - Ingest: platform markup to mention/link parts (incl. Slack shared-channel outsiders), thread routing both ways, auto-acknowledge once per open period, member-first outreach endpoint + SDKs + CLI, Teams manifest 1.1.0, hand-back from assigned_at. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
CodeQLNo new findings on lines this pull request changed (as of Updated on every push. A finding judged safe goes in |
|
Factory review: Changes neededround 2 · reviewed at Every line that leaves a pod takes one path out
What could break
299 of 67,793 changed lines worth reading. The rest is mechanical, generated or lockfiles. Decisions this PR makes
Findings · 2 open · 1 settled
Worth fixing · 2 warnings1. 0057 keys each opt-out by the stored spelling, but every lookup keys it by the normalised handle — The migration writes suppressions as Suggested fix: 2. 0058 is edited in place although main already carries revision 0058_channels —
Suggested fix:
Rounds
One comment, edited after every round. Last updated 20 Oct, 04:44 UTC. |
…dening (#1004) * Channels v2 (3/3): one live socket, API conventions, channels UI, hardening - app/core/live: the pod's /live socket in core with module topics (records, channel, inbox, conversation) and calls (typing, read, presence); per-channel rev cursor under the row lock; events projected through the REST mapper; hubs woken after commit, self-restarting. - API: /sources under /channels, channel.read/write/manage, DIRECT channel on join + backfill in 0058, notices under /members/{u}, contact cursors, assignee=me, needs_person filter, delivered on lines, caller-scoped idempotency keys, admin join of private groups; channel files checked and served through a visibility-checked endpoint. - Frontend on the socket: inbox bell ungated, read by seq, composer with files and mention chips, delivery status + retry, contact inbox views, channel settings, search, a11y; conversation pane over the socket. - Hardening: read budgets for a 10k-line channel and 500 channels per member, inbox mention-count fix, socket inbox recompute O(1) per reader, metrics, docs and skills swept; TS SDK retries unavailable. - From end-to-end QA: the pod agent is called by the pod's name; echoed <memory>/<index> context is stripped from answers; answers in a member's own channel follow the line; mention names filled on read; visibility is PUBLIC/PRIVATE; the chats list excludes channel conversations (`in_channel` filter); channel form controls and header layout. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2: address the stack review Privacy: a mirrored Slack or Teams place is private unless the event or the platform says it is public, a channel turned private takes its mirror private for good, and leave events (Slack, Teams, Telegram) drop the seats the platform gave. Source settings are hidden from who cannot see the channel. Delivery: an ambiguous or partial send is never retried blindly, Retry-After is honoured, cold email and pushes carry an idempotency key, a place's lines go out in the order they were written, and a removed bot's answer is skipped. Slack and Teams accounts are keyed by bot as well as workspace. Lines: where a line was heard is split from where it goes (target_source_id), so in-app lines no longer open a platform's reply window. Group seats default to MENTIONS, pushes skip members in the app and anyone a line is withheld from, and a budget-held push becomes one digest. Search filters on the pod, hides withheld lines in SQL, and returns a rank and snippet. API: GET /channels takes visibility and q; GET /channels/{c}/members; client ids are named per member, a reused key with another body is 422, and a demoted owner keeps the seat but not the say. Also: questions cancel in batches, chained contact merges follow the survivor, an emailed STOP needs passing authentication, and WhatsApp numbers are read as international. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2: apply the data-model review Write path: one channels write per post, fan-out after commit. Moving a line's rev is now a row in channel_changes, not a line rewrite, and thread counts live in channel_threads; rev semantics and the cursor are unchanged. ix_channels_pod_updated is gone, so channel updates are HOT again; the live clock is the database's. Pushes: worked out after commit and coalesced per member and channel (channel_push_pending), urgent ones at once; every seat is pushed, not an arbitrary 500. Deliveries carry a reason_code, channel_id and seq, claims are one index probe, attempts are recorded and pruned. Support state moves to channel_cases with an event history, readable at GET /channels/{c}/cases; the channel API keeps its fields. Integrity: mentions follow edits and deletes and are held to their line by a composite FK; questions name their asker by type and link to it; seats record how they were given; lines, places and threads are held to their channel by composite FKs; read state survives a fold, which now orders lines by time. Child tables no longer reference pods directly. Sources: the place lookup uses its unique index, one writer decides visibility, kinds are PERSON|CHAT, accounts are keyed by workspace and bot (filled in place), WhatsApp by phone-number id, and a heard line's reply target keeps no words, email or phone. Agent context is read from the timeline when the prompt is built; channel_line_answers records which line a run answers; contact lines reach a member's run marked untrusted; Slack, Teams and Telegram edits and deletes reach the heard line. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Import budget: re-record for the channels v2 modules 4662 -> 4752 modules at boot. All 90 are the stack's own app modules (channels, cases, change log, pushes, deliveries); no new third-party package is loaded. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2: fix the CI e2e failures and the factory review warnings - A resuming channel subscriber is told the cursor it resumes from, not the head, so a drop before its gap arrives replays the gap instead of skipping it. - 0058 drops any index a failed concurrent build left behind before building ix_agent_conversations_channel_id and uq_agent_conversations_channel_person, instead of keeping an invalid one under IF NOT EXISTS. - test_delivery_outcomes: undo only the injected blink, not the fixtures' fake-platform wiring (CI refused the loopback API). - test_delivery_claim_budget: the due sweep is deployment-wide, so its row ceiling allows the other tests' pending rows; the no-history checks stay exact. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
pip-audit fails on aiohttp 3.14.3 (CVE-2026-111063, CVE-2026-111064, GHSA-2g87-pp6c-29x5), all fixed in 3.14.4. Raises the floor, locks 3.14.4 exactly, and yarl 1.25.1, which it requires. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The seed ran ANALYZE after its commit and never committed it, so the statistics were rolled back and EXPLAIN planned on defaults. There the recipient's whole-history index ties with its partial due index, and CI picked the history one (3,007 rows). With the statistics kept, the probe is an index-only scan of ix_cmd_due_recipient. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…question rows (#1000) * Every outsider is a contact, and a conversation names the contact it answers Group strangers and anonymous web visitors become contacts on their first write, each in a conversation of their own; whom a conversation answers is the contact_id column rather than metadata; proving a handle another contact holds merges the two; visitor rows are named for their contact; the contact limit counts answered contacts. Migration 0057 backfills and retires the shared ~outsiders threads. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contact door e2e: bind by contact_id Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels module: tables, places, timeline, projection, API; migration 0058 Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels carry every line: group log, DMs and answers on the timeline The group log is now the group's channel timeline. Telegram and WhatsApp group messages are channel lines said by the member the sender resolved to, or by a contact made the first time they speak. Every message a run answers and every delivered answer is a line too, the answer said by the agent for the person it answered. A run's group background, the groups page and its roster read the channel, and agent_surface_group_messages is gone from the code. The binder places each thread on a channel source and continues the person's one conversation with the agent in that channel. That holds across numbers, bots, platforms and quiet days. The 24-hour DM reset and WhatsApp link adoption are deleted. Channel reads and writes sit behind two small ports (ChannelSeats, ChannelLines), so unit tests drive the binder, ingress and egress with in-memory fakes instead of patches. Also: a duplicate platform message is no longer announced twice, the Channels tag and module docs are added, and the Python and TypeScript SDKs are regenerated with channels facades. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contact channels: web chat lines, step-in handler, follow-ups as members Web chat joins the contact's channel as a WEB source. A visitor's message is their line, and the agent's answer is a line said for them. An answer to something asked in the web chat now stays out of any platform thread the same conversation has. The source it answers is the one the person last spoke on (answering_source), derived from the timeline, not stored separately. A member steps in on a contact's channel with PUT /channels/{id}/handler and hands back with DELETE. While a channel has a handler, no run starts for that contact. This replaces the expiring handed_to metadata. Reaching the cap still holds turns on its own, and held messages are on the timeline too. A follow-up a member sends is now that member's line on the channel, not the agent's. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts tests: pass vouched to visitor rows, mark the merge test as driving work Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channel places: unit tests for which place a message is and whose channel it joins Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels API: e2e for who sees what, and a list cursor that cannot skip The channel list paged on a timestamp alone, so two channels last active in the same instant could be skipped or repeated between pages. It now pages on time and id, like the timeline. The new channels API e2e covers: - another member's direct channel is missing to them, list and read alike; - only a member who may write to contacts takes a contact's channel, and only a contact's channel can be taken; - the timeline and the list page back without gaps; - reading marks the reader alone; - a channel is missing from another pod. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels review: keep the old group log one release; reading marks anyone Migration 0058 no longer drops agent_surface_group_messages. A pod still running the previous image writes every Telegram and WhatsApp group message there, and dropping the table under it would fail that message's ingest for the whole rollout window. Nothing in this version reads or writes the table; a later migration drops it. On downgrade, the lines the timeline gained since the upgrade are written back without duplicating the kept ones. Marking a channel read now stores the mark for a member who has never said anything there. It used to update a participant row only a speaker has, so for a reader it changed nothing. The mark is now an upsert that joins the reader. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts review: a merge moves the contact's rows; forgetting takes the merged When a web visitor proved an email an older contact held, the merge repointed their conversation but not the rows they had written in contact-owned tables. The person the pod now knew could not read those rows, and forgetting that person left them behind. The datastore now subscribes to ContactMerged: a JOB moves the merged contact's rows to the survivor in every contact-owned table of the pod, idempotently. Forgetting a contact also forgets the contacts merged into it. `merged_into_id` cascades; when it was set to null, the absorbed contacts came back as empty entries. The organization's contact-limit count, run before every outside turn, gets the partial index it reads: answered, unmerged contacts by pod. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts: no legacy handling; 0057 is schema only Groups and web chat were never released, so there is nothing of the old way to carry over. Migration 0057 now only adds the columns and indexes. Gone with that: - the metadata backfill; - turning old visitor sessions into contacts; - retiring the shared ~outsiders threads; - the downgrade's restore of metadata; - `retired_outsiders_thread`, `RetiredConversation` and `refuse_retired`; - the server-metadata protection for the retired-thread key. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts review: rename a merged contact's survivor; each half of the cap is chosen on its own A PATCH to a merged contact's id renamed the absorbed row and showed the survivor unchanged; it now renames the person the id leads to. A stored cap could not tell a half the owner never set from one they removed: a spend-only cap read its empty count as no limit, and a count-only update dropped the spend cap. usage_contacts_caps records which half was chosen (monthly_limit_set, max_contacts_set); an unchosen half follows the deployment's default, and an update keeps whatever it does not name. PS-SURF-049 is marked gap: a row from a page with no visitor session is stored naming no contact. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels: sources replace conversation links and the group tables agent_surface_conversation_links, agent_surface_groups and agent_surface_group_messages are dropped by 0058, which is schema only: groups and web chat were unreleased, and a member's private chat starts afresh in their direct channel. - Where a message was said is a channel source; how to write back is its delivery_ref and the inbound line's reply_ref; where a conversation's answer goes is reply_address. A group is a shared source carrying its settings, and its id is the source's. - Opening a person's conversation is serialised by an advisory lock on (channel, agent, person), taken after authorizing them -- authorizing commits, which ended the lock -- and held by a unique index. - Notifications land in the recipient's one conversation with the agent; the repoint and give-back logic is gone. Web chat and email follow-ups continue the contact's one conversation. - Every place read sits behind ChannelSeats, with an in-memory double. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels: format Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts review: PS-SURF-049's gap in product words, with DEV-SURF-004; say why 0057 carries nothing over Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts review: admit a new contact under a lock on the count, as the contact it is The count and the mark shared a transaction, which does not serialise two new contacts arriving together: both counted room for one more and both were marked. Admitting a contact not yet answered now takes a transaction lock on the organization's count first. And the mark goes to the contact a merged id leads to -- the one counted -- not the absorbed row. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts review: a contact's second turn finds the place its first took Two turns of one new contact arriving together both read it unanswered before the lock; the second then counted the first's place as one too many and was held. The contact is read again under the lock. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts review (Factory): forget reaches merged-in contacts; no count, no lock; a turn continues only its own person's conversation - Forgetting a contact forgets the contacts merged into it too. Their rows and conversations move to it in queued work, and a forget that came first left them naming a contact the cascade had deleted. - Admission takes the organization's count lock only where the cap keeps a count; otherwise the contact is marked and admitted at once. - A contact's turn continues only a conversation naming that contact, and a member's only one naming nobody -- through a routing key or a web chat's session. With 0057 carrying no old audience over, a conversation from before contacts were a column names nobody, and continuing it answered the person with the keeper's authority. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels: the journey world's conversations answer a member Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts review: a conversation naming a merged contact is the survivor's; forget pages through every merged contact - Whether a conversation answers a person follows a merge: one naming the contact a visitor was merged from is theirs before the merge's work moves it, so writing right after proving who they are keeps their chat. The move names the conversation's stored contact. - Forgetting reads the contacts merged into one a page at a time, so none is left past a cap for the cascade to strand. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels: contacts for editors, a default contacts limit, the channel app's schema, one way to name a person QA of the rebuilt branch, and what came out of it: - A visitor whose chat was merged into another contact's got a 500 on their next message: their conversation had lost its seat in the channel, and placing it again collided with the survivor's. The session now continues the conversation that holds the seat, or re-seats its own when none does. - Contacts, and their channels, are for a pod's editors and admins: a new `contact.read` permission (implied by `contact.message`, never held by a linked pod) gates `GET /contacts`, the CONTACT channels in `/channels`, and who may look after a bot's or a widget's contacts. The app hides the Contacts place from everybody else. - An organization's bots may answer 500 contacts by default (`USAGE_CONTACTS_MAX_DEFAULT`), until an owner chooses a number. - A member's follow-up reached the agent as its own earlier words, and the agent told the contact a member's answer was its own unverified guess. It is now marked as a member's. - `/channels/{id}/messages` showed every member an answer the bot made with another member's own access; it is withheld as the group page withholds it, by one rule in the projection. - Channel responses name everybody one way -- `{kind: MEMBER|CONTACT|AGENT, id, name}` -- for a line's sender and principal, a channel's person and handler, and its participants. - Migration 0058 also carries what the channel app and agent tools will build on, so that work needs no migration: threads, edits, files and run on a line; a question waiting on one person, and its answer; mentions; per-destination deliveries; notify preferences; a channel's topic, archiving and call for a person. The contact fold carries the new columns. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Surfaces e2e: read channel lines and channels by the PersonRef shape The contact-care and web-widget journeys still read the flat sender and person fields /channels no longer returns. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels app, part 1: a channel's lines, threads, questions and inbox The channels module grows from a timeline surfaces write into into the API a Slack-like app reads and writes: - Channels made in Lemma (a GROUP no outside place feeds): create, rename, re-describe, archive, add and remove members, leave. - Lines: post (to the place the channel last heard from, or a named one, or nowhere as a note to members), reply in a thread, edit and delete one's own, files as parts, mentions of members, contacts and agents. - What is new for a member: unread lines (from joining or the last read, capped), unread mentions, questions waiting on them; `/pods/{pod}/inbox`. - Questions on lines: a line can ask one person something; only they answer it; the asking module registers what answering does (`answers_for`), and the line closes after it took the answer. - Notify preferences per member per channel; search over what the reader sees. - A question is never withheld from the person it waits on, nor a line from its own direct channel's member. The row mappers move to `rows.py`, the timeline to `lines.LineRepository`. SDKs, contracts, route inventory and coverage regenerated. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels app, part 2: the pod's agents answer in Lemma's own channels A member writing in their own channel is answered by the pod's agent, as a DM would be; in any channel an agent answers a line that names it. The turn runs in the member's conversation with that agent in the channel, with their access, shown the thread or the channel's recent lines (never a contact's, never an answer withheld from them). The answer is a line where they wrote -- the same thread -- said for them (`surface.in_app_turn`, and `reply_for_run` answering in Lemma when that is where the person last spoke). The channel list makes the member's own channel the first time they look, as a DM with the bot is there before the first message. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels app, part 3: questions wait on the person they ask, as lines An agent's `ask_user` or `request_approval` is a line waiting on the member it asks -- in the conversation's channel, or their own channel for a chat in no channel -- so it is in their inbox wherever they are. Answered on the line, the paused run takes the answer as the conversation's own card gives it (authorized as them, platform cards retired); answered anywhere else, the line closes with that decision when the run next ends (`surface.pause_lines`). A workflow's form is a line waiting on its assignee in their own channel; answered there, the engine validates it (a bad answer is refused, the line stays open) and the run moves on. Submitted in the workflow, the line closes. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels app, part 4: what the pod tells a member, Slack and Teams, the Channels place What the pod tells a member -- a teammate's agent asking, a notice, a workflow's form -- is a line in their own channel, carried on to where they last spoke unless they read that channel in Lemma only. Notifications, their API, tools and inbox are gone; `channel_post`, `channel_read`, `channel_search`, `channel_list` and `answer_question` replace them, and an asker waiting on several answers is woken once the last one closes. A member's line in a contact's channel is carried to the contact under the same rules follow-ups kept (STOP, WhatsApp's 24 hours, a daily limit, an unsubscribe link on email), and each attempt is recorded on the line. Follow-ups are gone. Slack and Teams channels and group DMs are GROUP channels: everything said there is logged as lines, a platform thread as a thread, the bot's answer too (streamed ones included). A Teams group chat is no longer read as a DM. `/groups` retires: a group is its channel; the bot's side of it -- who answers its outsiders, starting a WhatsApp group, the Telegram link -- is `/pods/{pod}/sources`. The frontend's Groups place becomes Channels: a list, a timeline with threads, mentions and questions answered in place, a composer, and a panel for people, notifications and sources. `lemma channels` in the CLI. A hosted page keeps its visitor's chat in a first-party cookie. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels app review: a sealed chat cookie, one address unsubscribed, a place switched off A hosted page's chat cookie holds the session secret sealed with the deployment's cipher, never the secret itself, and is named by the widget's id rather than anything the request sent; a cookie this deployment did not seal is as if there were none. A member's line to a contact is decided by the handle of the place it goes to: unsubscribing one address no longer silences the contact's others, and a place that writes to the address they left is still refused. Channels or Contacts reached by its address with its flag off says it is not switched on, instead of an empty pane. Co-Authored-By: Claude Opus 5.5 <[email protected]> * CodeQL: the kept-chat cookie holds a sealed secret Co-Authored-By: Claude Opus 5.5 <[email protected]> * Surfaces e2e: each test starts the per-address web chat window afresh Every request in a shard comes from one client address, so the tests that ran first spent the 30-chats-per-10-minutes window and a later one was refused with 429. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels: a channel's own settings, as one JSON column Whether a contact's channel is answered by its agents, and the switches that will follow it, live in channels.settings: an object, absent keys their defaults, so the channel app adds a setting without a migration. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Contacts: a switch for whether their agent answers, in place of take and release A contact's channel has one explicit control: "Agent answers". Off, no run starts for the contact and nothing tells them a person will reply; the channel waits on a reply and the member who looks after them is told. On again, the contact is the agent's at once, whoever had stepped in. Stepping in stays implicit: writing to the contact holds them while the member is in the conversation, and lets go after the channel's `hand_back_after_hours` (12) of quiet, telling the member. Both live in `channels.settings` (#993's 0058), changed with PATCH /channels/{id}; PUT/DELETE /channels/{id}/handler, the SDKs' take/release and the CLI's are gone (`lemma channels agent <id> on|off`). A member holding a contact no longer sends them the contacts cap's "a person will reply", nor flags their channel with the cap as its reason. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels: no channel_agents table It was written whenever an agent spoke and read by nothing: which agents spoke in a channel is on its lines (sender_agent_id), and which agent answers is the surface's, or whoever a line names. A roster of the agents a channel may call on, if one is wanted, is a table of its own later. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2: one visibility rule, one write path, delivery and question rows Rework #1000 as PR 1 of 3 on the agreed v2 design (0057/0058 edited in place; unreleased). - channels: external accounts; visibility (public/private) with one predicate in Python and SQL; gapless per-channel seq under the channel lock; read state by seq; members with role/notify/answers; questions table with claim, expiry and per-pod refs; delivery rows unique per line and target with backoff/DEAD; contact inbox columns (agents_answer, assignee, status, attention). - Timeline.post is the only write path: seats, mentions, questions, contact assignment, answering agents, deliveries and pushes, facts. - agent_surfaces: account-scoped places; a delivery executor (surface.channel_delivery) replaces member posts, DM fan-out and notification dispatch; in-app turns, pause lines and asked-questions work move out. - agent: one turn per (line, agent); pause questions as tool: askers; asked-question batches wake once; channel tools on the new contracts. - workflow: forms as wf: questions. - API: channels, members, read, messages, threads, answer, deliveries, inbox, search; SDKs, CLI and frontend ported. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Bundle cutover test: skip the notifications seed 0058 drops The 0056 cutover also seeds `notifications`, which this database is past; filter it as the agent's wait-work cutover test does. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2 (2/3): surfaces as transport, one outbound path, contacts policy (#1001) * Channels v2 (2/3): surfaces as transport, one outbound path, contacts policy - Agent answers are lines first, then delivery rows; streamed answers are recorded SENT with the stream's id, never posted twice. Every adapter returns the platform id; late WhatsApp and Resend failures (bounce, complaint, failed) mark the delivery DEAD by id; send-then-crash is never resent. Redis wamid map removed. - contacts.contracts.outreach: may_send (suppression, handle, window), daily count once per line, opt-out first on every path, suppressions by hashed handle, START/typed-only opt-in, E.164 phones, host issuers, List-Unsubscribe one-click. Bounces and complaints suppress. - Ingest: platform markup to mention/link parts (incl. Slack shared-channel outsiders), thread routing both ways, auto-acknowledge once per open period, member-first outreach endpoint + SDKs + CLI, Teams manifest 1.1.0, hand-back from assigned_at. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2 (3/3): one live socket, API conventions, channels UI, hardening (#1004) * Channels v2 (3/3): one live socket, API conventions, channels UI, hardening - app/core/live: the pod's /live socket in core with module topics (records, channel, inbox, conversation) and calls (typing, read, presence); per-channel rev cursor under the row lock; events projected through the REST mapper; hubs woken after commit, self-restarting. - API: /sources under /channels, channel.read/write/manage, DIRECT channel on join + backfill in 0058, notices under /members/{u}, contact cursors, assignee=me, needs_person filter, delivered on lines, caller-scoped idempotency keys, admin join of private groups; channel files checked and served through a visibility-checked endpoint. - Frontend on the socket: inbox bell ungated, read by seq, composer with files and mention chips, delivery status + retry, contact inbox views, channel settings, search, a11y; conversation pane over the socket. - Hardening: read budgets for a 10k-line channel and 500 channels per member, inbox mention-count fix, socket inbox recompute O(1) per reader, metrics, docs and skills swept; TS SDK retries unavailable. - From end-to-end QA: the pod agent is called by the pod's name; echoed <memory>/<index> context is stripped from answers; answers in a member's own channel follow the line; mention names filled on read; visibility is PUBLIC/PRIVATE; the chats list excludes channel conversations (`in_channel` filter); channel form controls and header layout. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2: address the stack review Privacy: a mirrored Slack or Teams place is private unless the event or the platform says it is public, a channel turned private takes its mirror private for good, and leave events (Slack, Teams, Telegram) drop the seats the platform gave. Source settings are hidden from who cannot see the channel. Delivery: an ambiguous or partial send is never retried blindly, Retry-After is honoured, cold email and pushes carry an idempotency key, a place's lines go out in the order they were written, and a removed bot's answer is skipped. Slack and Teams accounts are keyed by bot as well as workspace. Lines: where a line was heard is split from where it goes (target_source_id), so in-app lines no longer open a platform's reply window. Group seats default to MENTIONS, pushes skip members in the app and anyone a line is withheld from, and a budget-held push becomes one digest. Search filters on the pod, hides withheld lines in SQL, and returns a rank and snippet. API: GET /channels takes visibility and q; GET /channels/{c}/members; client ids are named per member, a reused key with another body is 422, and a demoted owner keeps the seat but not the say. Also: questions cancel in batches, chained contact merges follow the survivor, an emailed STOP needs passing authentication, and WhatsApp numbers are read as international. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2: apply the data-model review Write path: one channels write per post, fan-out after commit. Moving a line's rev is now a row in channel_changes, not a line rewrite, and thread counts live in channel_threads; rev semantics and the cursor are unchanged. ix_channels_pod_updated is gone, so channel updates are HOT again; the live clock is the database's. Pushes: worked out after commit and coalesced per member and channel (channel_push_pending), urgent ones at once; every seat is pushed, not an arbitrary 500. Deliveries carry a reason_code, channel_id and seq, claims are one index probe, attempts are recorded and pruned. Support state moves to channel_cases with an event history, readable at GET /channels/{c}/cases; the channel API keeps its fields. Integrity: mentions follow edits and deletes and are held to their line by a composite FK; questions name their asker by type and link to it; seats record how they were given; lines, places and threads are held to their channel by composite FKs; read state survives a fold, which now orders lines by time. Child tables no longer reference pods directly. Sources: the place lookup uses its unique index, one writer decides visibility, kinds are PERSON|CHAT, accounts are keyed by workspace and bot (filled in place), WhatsApp by phone-number id, and a heard line's reply target keeps no words, email or phone. Agent context is read from the timeline when the prompt is built; channel_line_answers records which line a run answers; contact lines reach a member's run marked untrusted; Slack, Teams and Telegram edits and deletes reach the heard line. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Import budget: re-record for the channels v2 modules 4662 -> 4752 modules at boot. All 90 are the stack's own app modules (channels, cases, change log, pushes, deliveries); no new third-party package is loaded. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Channels v2: fix the CI e2e failures and the factory review warnings - A resuming channel subscriber is told the cursor it resumes from, not the head, so a drop before its gap arrives replays the gap instead of skipping it. - 0058 drops any index a failed concurrent build left behind before building ix_agent_conversations_channel_id and uq_agent_conversations_channel_person, instead of keeping an invalid one under IF NOT EXISTS. - test_delivery_outcomes: undo only the injected blink, not the fixtures' fake-platform wiring (CI refused the loopback API). - test_delivery_claim_budget: the due sweep is deployment-wide, so its row ceiling allows the other tests' pending rows; the no-history checks stay exact. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> * Bump aiohttp to 3.14.4 for three new advisories pip-audit fails on aiohttp 3.14.3 (CVE-2026-111063, CVE-2026-111064, GHSA-2g87-pp6c-29x5), all fixed in 3.14.4. Raises the floor, locks 3.14.4 exactly, and yarl 1.25.1, which it requires. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Claim budget test: commit the ANALYZE it plans on The seed ran ANALYZE after its commit and never committed it, so the statistics were rolled back and EXPLAIN planned on defaults. There the recipient's whole-history index ties with its partial due index, and CI picked the history one (3,007 rows). With the statistics kept, the probe is an index-only scan of ix_cmd_due_recipient. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
PR 2 of 3 for channels v2. It is stacked on #1000 (PR 1) and targets that PR's branch; it should be retargeted to
mainonce #1000 merges. It edits0057and0058in place, since neither has been released.What changes
agent_surfacesbecomes transport only. Every outbound send is a delivery row, the agent's answers included. The outreach policy moves intocontacts, and what is heard reads as people and links rather than markup.One outbound path
client_message_id=run:{run_id}anddeliver_toset to its place. The delivery executor then sends it.SENTwith the stream's message id. It is not posted a second time.SENTat once.send_messagereturns the platform's id: the Slackts, the Teams activity id, the Telegram message id, the WhatsApp wamid, or the Resend email id. It is recorded on the delivery row, for pushes as well.Idempotency-Key, so a retried delivery is the same email.email.bounced(permanent),email.complainedoremail.failedeach mark the deliveryDEADby its platform id.SENTwhen its platform id was noted before the call, otherwiseDEAD(INTERRUPTED), which a member can retry. Each send is bounded at 60 s, under a 90 s claim lease.SKIPPEDat once instead of retried.Contacts policy (
contacts.contracts.outreach)may_send(pod, contact, to, kind, at, window)returns aVerdict: allowed, plus a code, reason, retryable flag, footer and unsubscribe token. The order is suppression first, then whether a handle exists, then the window.contact_suppressionsrow. The row is keyed by the hashed handle, so it survives the contact being forgotten.Auto-Submitted, out of office,Precedence: bulk) and unauthenticated mail never lift it.phonenumbers(contacts_default_phone_region).host:{issuer}:{sub}, with an issuer per web widget (host_issuer, set only at creation, because changing it would make every host contact a new one).List-Unsubscribe(https and mailto) andList-Unsubscribe-Post: List-Unsubscribe=One-Click.agent_surfacessupplies, read from the place'slast_inbound_at: Meta keeps the window per business number and customer.Ingest, threads and the contact inbox
Markup becomes parts. The following become
@Namemention parts and link parts:<@U…>,<url|label>and<#C|name>;<at>entities and<a href>links;text_mentionandtext_linkentities;@<number>mentions.Each mention resolves to a member, a known contact, or the agent the bot answers for. A Slack user from another company in a shared channel resolves to their contact by user id.
Threads go home both ways. A reply written in Lemma under a platform thread is posted in it (Slack
thread_ts, Teams;messageid=). A platform reply to a line written in Lemma lands under that line. A top-level line goes to the place itself.Auto-acknowledge. Set with
PodConfig.contacts.auto_acknowledge, where{pod}is replaced by the pod's name. When a contact's agents are off, the line is sent at most once per open period: a member writing, or the channel closing, starts a new one. It goes throughmay_send(ACK).Member-first outreach.
POST /pods/{pod_id}/contacts/{contact_id}/outreach {handle, kind?, text, client_message_id?}writes to a contact first.PodContacts.outreachandContactsNamespace.outreach; CLIlemma channels outreach.Hand-back. An assignee who hasn't written yet is held from
assigned_at. Before, an explicitly assigned member was let go on the contact's very next message.Teams manifest 1.1.0 adds the RSC permissions PR 1 introduced. A test now pins a digest of the manifest per version, and the committed zip is rebuilt from the manifest.
Why
PR 2 of the channels v2 plan (#1000's description has the design). Design decisions D3 and D7: every outbound send is a delivery row, and
agent_surfacesis transport only. The product rules come with them: STOP on every path, unsubscribe headers, thread routing, and member-first outreach.Bugs found and fixed on the way
apply_platform_updatenever committed when a lifecycle event was handled first, so WhatsApp status failures were not persisted.FAILED:at_place_levelsetexternal_thread_id=None, which failed validation.assign()clearedassignee_last_line_at.lemma-teams.ziphad none of the RSC permissions, andmake teams-manifest-zipcalled a script that doesn't exist.raw_payload, which no reply reads.How to verify
Client suites: lemma-python 190 passed; lemma-cli 884 passed; lemma-typescript 463 passed; lemma-frontend 1556 passed.
New e2e tests:
test_run_answer_deliveries_e2e.py: run finished twice; send-then-crash with and without the platform id; WhatsApp 131047 by wamid; a streamed Slack answer is one line and is not posted twice;test_email_bounces_e2e.py: a permanent bounce or a complaint marks the deliveryDEADand the next line isSKIPPED; a temporary bounce or an unknown email changes nothing;contacts/tests/e2e/test_outreach_e2e.py:test_handle_spelling_migration_e2e.py;test_channel_markup_threads_e2e.py: parts and mentions, visibility of mentions, Slack threads both ways, a Teams thread, a shared-channel outsider;test_contact_acknowledgement_e2e.py,test_contact_outreach_e2e.py,test_contact_hand_back_e2e.py;test_contact_door_e2e.py.Checklist
0057addscontact_identities.issuerandvalue_raw, rewrites stored phones to E.164 and host values tohost:default:…, movesunsubscribed_atintocontact_suppressions, and addsagent_surface_web_widgets.host_issuer;0058addschannels.acknowledged_atandchannel_message_deliveries.claimed_at;lemma-client.jsregenerated (outreach endpoint,host_issuer)0058and0057; nothing has been releasedCompatibility and rollback notes
surface_contact_follow_ups_per_contact_per_dayis replaced bycontacts_member_lines_per_contact_per_day. New:contacts_default_phone_region./surfaces/webhooks/resend) now also needs theemail.bounced,email.complainedandemail.failedevents. Identity's/auth/email/bounces/resendstill deactivates accounts whose auth email bounces. Point Resend at both, or let both share one endpoint secret.APPROVED_ACTION_FAILED_NOTICE, are no longer written as timeline lines (follow-up);🤖 Generated with Claude Code