Repository navigation
Channels v2 (1/3): one visibility rule, one write path, delivery and question rows - #1000
Conversation
…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]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
… 0058 Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
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]>
…driving work Co-Authored-By: Claude Opus 5.5 <[email protected]>
…nnel it joins Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
…yone 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]>
…he 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]>
…lemma/channels-2-channels
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]>
…lemma/channels-2-channels # Conflicts: # lemma-backend/app/modules/agent/domain/outsiders.py
…acts # Conflicts: # lemma-python/lemma_sdk/_spec_info.py
… 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]>
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]>
…lemma/channels-2-channels # Conflicts: # lemma-python/lemma_sdk/_spec_info.py
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…4; say why 0057 carries nothing over Co-Authored-By: Claude Opus 5.5 <[email protected]>
…lemma/channels-2-channels
…e 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]>
…lemma/channels-2-channels # Conflicts: # lemma-backend/app/modules/agent_surfaces/services/outside_cap.py
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]>
…lemma/channels-2-channels
…t, 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]>
…lemma/channels-2-channels # Conflicts: # lemma-backend/app/modules/agent_surfaces/services/web_chat.py # lemma-backend/app/modules/agent_surfaces/tests/unit/test_dm_continuity.py
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
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]>
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]>
…he 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]>
WalkthroughWarning Review details and warnings were omitted to fit the comment limit. |
CodeQLNo new findings on lines this pull request changed (as of Updated on every push. A finding judged safe goes in |
|
Factory review: Needs a person's reviewround 5 · reviewed at One rule and one write path replace groups, notifications and per-producer sends
What could break
362 of 95,734 changed lines worth reading. The rest is mechanical, generated or lockfiles. Decisions this PR makes
Findings · 3 open · 3 settled
Worth fixing · 3 warnings1. A run paused when this deploys gets no line waiting for its member —
Suggested fix: On deploy, run the sync for every conversation with an undecided pause, or enqueue 2. 0058 is edited in place, so an environment that already applied it gets none of the new tables — The revision id is unchanged from main -- Suggested fix: 3. Dropping the notifications table deletes every question that was still waiting on a member —
Suggested fix:
Rounds
One comment, edited after every round. Last updated 19 Oct, 07:35 UTC. |
…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]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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]>
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]>
# Conflicts: # lemma-backend/docs/modules/channels.md
…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]>
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]>
# Conflicts: # lemma-backend/app/modules/channels/services/timeline.py
…on 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]>
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]>
… 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]>
PR 1 of 3 for channels v2. Builds on #993 (merged), and replaces both its schema and this PR's first version.
0057and0058are edited in place because neither has been released.What changes
Channels own every line a person or an agent writes, and everything that follows from it: who can see it, who is told, which agents answer, what it asks and how it is delivered.
agent_surfaceskeeps the platforms; the rules move intochannels,agentandworkflow.One visibility rule.
domain/access.may_seeand its SQL twininfrastructure/access.visible_toare used by every read: channel list and get, messages, threads, inbox, search, agent tools and background.DIRECTis visible only to its member.CONTACTis visible to members withcontact.read.GROUPis visible to the whole pod when public, and to its members when private.One write path.
Timeline.postdoes everything in one transaction:seq, under the channel lock) and seats the speaker;A line is written once per
client_message_id, and once per platform message.Deliveries are rows. Every send outside Lemma is a
channel_message_deliveriesrow, unique per line and target. That covers a line for a place and a push to a member. A new executor (agent_surfaces/services/channel_deliveries.py, kindsurface.channel_delivery) sends each row:SENT,SKIPPEDwith the reason, orFAILED;DEADafter 6 tries; a sweeper re-announces rows that are due;notify_viasays, otherwise where the sending agent can best reach them.This replaces
member_posts,DirectMessages.deliver, the notification dispatch,said_in_run,line_went_outandopened.Questions are rows.
channel_questionsholds:refunique per pod;OPEN → ANSWERING → ANSWERED | CANCELLED | EXPIRED;An answer is claimed before the asker takes it, so a second tap gets 409. Approvals are answered only by the person's own tap. An agent answering one gets 403, and an answer without a decision gets 422.
tool:covers agent pauses (agent/services/pause_questions.py), and multi-questionask_useris answered field by field;wf:covers workflow forms (workflow/infrastructure/form_questions.py), whose ref iswf:{run}:{node}:{wait}so a looping graph can ask again;msg:covers one member's agent asking another, and its batch wakes the asker once.Read state by seq.
channel_read_state.last_read_seq; unread islast_seq − read_seqfor joined channels.External accounts. A place belongs to an account rather than a surface: a Slack team, Teams tenant, Telegram bot, inbox or web widget, each from
account_key_for. A reinstalled bot keeps its places. Every lookup is scoped to the asking surfaces' accounts. Only signed routing reads that prove no narrower scope look across a whole platform.In-app answering moves to
agent.agent.channel_lineandagent.channel_answerrun one turn per (line, agent), and the answer lands in the line's thread.in_app_turns,pause_lines,asked_questions_work,question_factsand the workflow-notification adapter are deleted fromagent_surfaces.Contacts' inbox fields are columns:
agents_answer,hand_back_after_hours,assignee_user_idwithassignee_last_line_at,statusandattention_*. ThesettingsJSON andhandlerare gone. The roster ischannel_memberswithrole,notify,notify_via,muted_until, andanswersfor agents.A member leaving the pod is taken out of its channels and unassigned, their questions are cancelled, and their own channel is archived.
API (
/pods/{pod_id})/channelsGET (kind, archived, contact, assignee, status, joined; cursor) · POST (Idempotency-Key) ·/channels/meGET · POST ·/channels/{c}GET · PATCH (absent = unchanged,null= clear) · DELETE ·/membersPOST ·/members/mePOST · PATCH · DELETE ·/members/{kind}:{id}PATCH · DELETE ·/readPOST{seq}·/messagesGET (before_seq/after_seq) · POST (client_message_id) ·/messages/{m}PATCH · DELETE ·/thread·/answer·/deliveriesGET ·/deliveries/{d}/retry·/inbox·/search. GETs never write.SDKs (Python
pod.channels, TypeScriptchannels), the CLI (lemma channels …, members, messages, deliveries,agent <id> on|off) and the existing frontend channel screens are ported to it. The full UI is PR 3.Why
A review of the first version of this PR found about 100 problems. Most came from a missing model rather than from bugs:
agent_surfacesowning in-app turns, questions, DM fan-out and contact policy.The design and its decisions are agreed (D1 to D7: one visibility rule, posting and telling as one step, delivery rows, question rows, accounts, seq, and surfaces as transport only).
Next:
Bugs found and fixed on the way
said_in_runreadChannelMessageModel.text; the column isbody./inbox: every call raised. The membership EXISTS subquery auto-correlated its own table away.channel_sourceswere missing, so every inbound message scanned every place on the platform (11 rows read grew to 411 in the plan test).set_ownernow seats them.client_message_id: a long provider call id overflowed the column (varchar(64)). Long keys are hashed.(channelId, podId)becausepod_idcame last in the handlers. They now declare it first.How to verify
Client suites: lemma-python 189 passed; lemma-cli 883 passed; lemma-typescript 463 passed and the build is clean; lemma-frontend 1556 passed.
New e2e tests:
channels/tests/e2e/test_channels_v2_api_e2e.py(17): visibility on every read, idempotency, seq paging, read state, threads, deliveries, questions, roster, inbox;agent_surfaces/tests/e2e/test_channel_deliveries_e2e.py(15): pushes, notify prefs, mute andNONE, cold email and its reply, retry and backoff toDEAD, the daily cap counted once, outage retried, deleted lines;agent/tests/e2e/test_channel_answers_e2e.py(8) andtest_channel_tools_e2e.py(4);workflow/tests/e2e/test_form_questions_e2e.py(3);test_channel_fold_e2e.py(contact merge renumbersseq) andtest_channels_migration_e2e.py(0058 up, down, up).Checklist
0058upgrade, downgrade and upgrade again tested (test_channels_migration_e2e.py);0057addscontact_suppressionslemma-client.jsregenerated and committedCompatibility and rollback notes
Breaking API changes, accepted on purpose because nothing here has been released:
channel.mark_read(nowchannel.read {seq}),channel.message.search(nowchannel.search),channel.notify.update(nowmembers/me), andchannel.participant.add/remove(nowmembers);agent.notice.sendno longer takestitle, and no longer returnsdelivered_via/reason, because a push is sent asynchronously and its outcome is on the delivery row;channel_postlostplatform: pushes follow the member'snotify_via.Migrations:
0057and0058are rewritten in place. They are unreleased, but a database that already ran the old0058has to be downgraded to0057and upgraded again.Baselines: four
channelsreads are recorded inunbounded-reads-baseline.json, each bounded in a way the AST pass can't see:_with_questionand_with_surfaceare statement builders, and every caller adds its ownlimit;list_foris the membership subquery, under an outerlimit;InboxReads.unreadare narrowed to the ids of the page being shown.The two notification-repository entries drop out with their file.
Not in this PR: the platform's message id on delivery rows (PR 2, with the egress executor); moving
/sourcesunder/channelsand thechannel.*permission names (PR 3, API conventions).Rollback: revert the PR, then downgrade
0058→0057. Channels start empty again; nothing has been released, so there is no data to carry back.🤖 Generated with Claude Code