Skip to content

Channels v2 (1/3): one visibility rule, one write path, delivery and question rows - #1000

Merged
anukuljha merged 52 commits into
mainfrom
lemma/channels-3-app
Oct 20, 2026
Merged

anukuljha merged 52 commits into
mainfrom
lemma/channels-3-app

Conversation

@anukuljha

@anukuljha anukuljha commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

PR 1 of 3 for channels v2. Builds on #993 (merged), and replaces both its schema and this PR's first version. 0057 and 0058 are edited in place because neither has been released.

Schema revised further up the stack. Two reviews of the whole stack were applied on #1004, which edits this PR's 0058 tables in place: support state moves to channel_cases, rev moves to a channel_changes log, pushes coalesce in channel_push_pending, deliveries gain reason_code/channel_id/seq, mentions, questions, threads and places get composite FKs, channel_sources.kind becomes PERSON/CHAT, and external_accounts is keyed by workspace_key + bot_key. Read #1004's "Data-model review" section for the final shape; the three PRs merge together.

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_surfaces keeps the platforms; the rules move into channels, agent and workflow.

  • One visibility rule. domain/access.may_see and its SQL twin infrastructure/access.visible_to are used by every read: channel list and get, messages, threads, inbox, search, agent tools and background.

    • DIRECT is visible only to its member.
    • CONTACT is visible to members with contact.read.
    • GROUP is visible to the whole pod when public, and to its members when private.
    • A group mirrored from a platform is private when the platform keeps it to its members. A group made in Lemma is public by default.
  • One write path. Timeline.post does everything in one transaction:

    • numbers the line (a gapless per-channel seq, under the channel lock) and seats the speaker;
    • records mentions and the question the line asks;
    • moves a contact's channel along: a member writing to the contact is assigned and the channel waits on the contact; the contact writing reopens it;
    • plans deliveries and pushes, and announces them.

    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_deliveries row, 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, kind surface.channel_delivery) sends each row:

    • it claims the row, applies the contact rules (STOP, WhatsApp's 24 hours, the daily limit counted once per line, the unsubscribe line on email), sends, and records SENT, SKIPPED with the reason, or FAILED;
    • a failed row is retried with backoff and becomes DEAD after 6 tries; a sweeper re-announces rows that are due;
    • a member is pushed where their notify_via says, otherwise where the sending agent can best reach them.

    This replaces member_posts, DirectMessages.deliver, the notification dispatch, said_in_run, line_went_out and opened.

  • Questions are rows. channel_questions holds:

    • a ref unique per pod;
    • a state, OPEN → ANSWERING → ANSWERED | CANCELLED | EXPIRED;
    • an expiry, enforced by a sweeper.

    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.

    • Askers register by prefix:
      • tool: covers agent pauses (agent/services/pause_questions.py), and multi-question ask_user is answered field by field;
      • wf: covers workflow forms (workflow/infrastructure/form_questions.py), whose ref is wf:{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 is last_seq − read_seq for 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_line and agent.channel_answer run one turn per (line, agent), and the answer lands in the line's thread.

    • Agents answer a line that names them, every line if they're set to, and in a member's own channel the pod's agent answers.
    • in_app_turns, pause_lines, asked_questions_work, question_facts and the workflow-notification adapter are deleted from agent_surfaces.
  • Contacts' inbox fields are columns: agents_answer, hand_back_after_hours, assignee_user_id with assignee_last_line_at, status and attention_*. The settings JSON and handler are gone. The roster is channel_members with role, notify, notify_via, muted_until, and answers for 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})

/channels GET (kind, archived, contact, assignee, status, joined; cursor) · POST (Idempotency-Key) · /channels/me GET · POST · /channels/{c} GET · PATCH (absent = unchanged, null = clear) · DELETE · /members POST · /members/me POST · PATCH · DELETE · /members/{kind}:{id} PATCH · DELETE · /read POST {seq} · /messages GET (before_seq/after_seq) · POST (client_message_id) · /messages/{m} PATCH · DELETE · /thread · /answer · /deliveries GET · /deliveries/{d}/retry · /inbox · /search. GETs never write.

SDKs (Python pod.channels, TypeScript channels), 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:

  • delivery and questions decided by each producer;
  • three copies of the visibility rule;
  • no idempotency key for sends;
  • agent_surfaces owning 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:

  • PR 2: surfaces as transport, with agent replies on delivery rows and a full contacts policy.
  • PR 3: one live socket, API conventions, the full UI, and hardening.

Bugs found and fixed on the way

  • said_in_run read ChannelMessageModel.text; the column is body.
  • /inbox: every call raised. The membership EXISTS subquery auto-correlated its own table away.
  • Retrying a send:
    • a retried send to a contact spent the contact's daily limit, and a Redis outage gave up instead of retrying;
    • a push that failed because a platform was down was recorded as unreachable and never retried.
  • Inbound reads: the chat and person indexes on channel_sources were missing, so every inbound message scanned every place on the platform (11 rows read grew to 411 in the plan test).
  • Telegram: the member who brought the bot into a group owned it but wasn't in its channel. set_owner now seats them.
  • Cold emails: a cold email's place was keyed by thread, while the reply is keyed by address, so each cold email left a duplicate place.
  • A call for a person:
    • it was shown only on contact channels, so a call in a group was invisible;
    • a member writing in the group now answers it.
  • DIRECT channels: a notice from one member to another seated the sender in the recipient's own channel. Only its owner is ever seated there now.
  • Notes: a members-only note was pushed outside the app. It now waits in the app unless it names you.
  • Questions: a second line asking an existing question ref silently got the first line's question. It now raises.
  • client_message_id: a long provider call id overflowed the column (varchar(64)). Long keys are hashed.
  • Generated TypeScript client: it took (channelId, podId) because pod_id came last in the handlers. They now declare it first.
  • Edits: an edit could name someone who can't see the channel, which a post refuses.

How to verify

make quality                     # 32 quality gates passed
make quality-frontend            # pass
make format-check                # pass
cd lemma-backend
uv run pytest app -m "not e2e" -n 8 -q          # 11646 passed, 153 skipped
uv run pytest app/modules/channels/tests app/modules/contacts/tests/e2e \
  app/modules/workflow/tests/e2e/test_form_questions_e2e.py \
  app/modules/agent/tests/e2e/test_channel_answers_e2e.py \
  app/modules/agent/tests/e2e/test_channel_tools_e2e.py \
  app/modules/agent/tests/e2e/test_message_reply_delivery_e2e.py -n 4 -q   # 55 passed
uv run pytest app/modules/agent_surfaces/tests/e2e -n 4 -q   # 446 passed

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 and NONE, cold email and its reply, retry and backoff to DEAD, the daily cap counted once, outage retried, deleted lines;
  • agent/tests/e2e/test_channel_answers_e2e.py (8) and test_channel_tools_e2e.py (4);
  • workflow/tests/e2e/test_form_questions_e2e.py (3);
  • test_channel_fold_e2e.py (contact merge renumbers seq) and test_channels_migration_e2e.py (0058 up, down, up).

Checklist

  • Tests cover the behavioral change
  • Lint, typecheck, and architecture gates pass locally
  • Migrations: 0058 upgrade, downgrade and upgrade again tested (test_channels_migration_e2e.py); 0057 adds contact_suppressions
  • API surface: OpenAPI spec, both SDKs and lemma-client.js regenerated and committed
  • Compatibility: see below
  • Security: visibility checked on every read path; approvals human-only; no new secret in logs or payloads
  • Rollback: see below

Compatibility and rollback notes

  • Breaking API changes, accepted on purpose because nothing here has been released:

    • removed: channel.mark_read (now channel.read {seq}), channel.message.search (now channel.search), channel.notify.update (now members/me), and channel.participant.add/remove (now members);
    • agent.notice.send no longer takes title, and no longer returns delivered_via/reason, because a push is sent asynchronously and its outcome is on the delivery row;
    • the agent tool channel_post lost platform: pushes follow the member's notify_via.
  • Migrations: 0057 and 0058 are rewritten in place. They are unreleased, but a database that already ran the old 0058 has to be downgraded to 0057 and upgraded again.

  • Baselines: four channels reads are recorded in unbounded-reads-baseline.json, each bounded in a way the AST pass can't see:

    • _with_question and _with_surface are statement builders, and every caller adds its own limit;
    • the flag in list_for is the membership subquery, under an outer limit;
    • the reads in InboxReads.unread are 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 /sources under /channels and the channel.* 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

anukuljha and others added 30 commits October 17, 2026 01:37
…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]>
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]>
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]>
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]>
…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]>
…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
anukuljha and others added 4 commits October 18, 2026 19:15
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]>
@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

Warning

Review details and warnings were omitted to fit the comment limit.

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

CodeQL

No new findings on lines this pull request changed (as of eb86dd6).

Updated on every push. A finding judged safe goes in .codeql-allow.txt with its reason, or is dismissed in the Security tab. GitHub Code Quality posts its own review comments separately.

Comment thread lemma-backend/app/modules/agent_surfaces/api/controllers/public_web_controller.py Outdated
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Module Unit E2E only Combined
Overall 73.2% 82.1% 91.5%
agent 78.7% 83.3% 92.8%
agent_surfaces 73.0% 82.8% 90.8%
apps 75.7% 87.6% 92.4%
connectors 78.7% 73.6% 90.2%
datastore 69.0% 86.4% 90.9%
function 70.2% 87.6% 92.8%
icon 88.8% 77.1% 89.6%
identity 64.4% 86.1% 92.5%
pod 62.3% 89.5% 92.0%
pod_bundle 75.8% 69.3% 88.1%
schedule 63.5% 88.9% 91.7%
usage 74.4% 84.5% 91.9%
workflow 70.1% 82.9% 89.9%
workspace 79.4% 59.4% 86.8%

@deepak-jha-kgp

deepak-jha-kgp commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Factory review: Needs a person's review

round 5 · reviewed at 54d6d8f · updated 19 Oct, 07:35 UTC · waiting on @deepak-jha-kgp · high risk

One rule and one write path replace groups, notifications and per-producer sends

  • Before: A pod's groups were read through SpaceGroups and their lines written by channels.services.timeline.Timeline; a question waiting on a member was a notifications row, kept and dispatched by NotificationService and shown by a bell of its own. Each producer -- a member's post, a DM fan-out, a notification -- planned and sent its own deliveries, and nothing decided who may see a channel in one place.
  • After: Every line is written through channels.services.posting.Timeline.post, which seats the speaker, records who it names, asks what it asks, plans one delivery row per target and announces the line in one transaction; one predicate decides who sees a channel, in Python (may_see) and in SQL (visible_to). A question is a row on the line it was asked on, claimed before the asker takes the answer.

What could break

  • If any environment already applied revision 0058_channels, then alembic sees the schema as current and none of the new DDL runs there, and the app queries tables that do not exist (medium likelihood, high impact; guard: the commit says 0057/0058 are unreleased; nothing in code enforces it)
  • If a member holds contact.read, then every contact's channel -- their DMs, emails and web chats -- is listed, searchable and readable to them, including conversations they never worked (medium likelihood, high impact; guard: contact.read is granted deliberately, and test_channels_v2_api_e2e.py:228 asserts the denial without it)
  • If the deploy runs 0058 with an open notification in the table, then that member's pending ask or approval is deleted with the table and never appears as a waiting line, for them (high likelihood, medium impact; guard: none)

362 of 95,734 changed lines worth reading. The rest is mechanical, generated or lockfiles.

Decisions this PR makes

  1. The migration drops the notifications table and the surface-group tables and carries nothing over — A member's open ask or approval is a notifications row today; dropping the table deletes it, and no backfill makes it a waiting line, so the deploy silently loses work that was waiting on a person (lemma-backend/migrations/versions/2026-10-17_channels_0058.py)
  2. One visibility rule decides every read, in Python and in SQL — may_see and visible_to must agree on four cases -- DIRECT, CONTACT, public GROUP, private GROUP -- or a channel is listed and not readable, or readable and not listed (lemma-backend/app/modules/channels/domain/access.py)
  3. Every line is written through Timeline.post, which plans deliveries and questions in one transaction — If any producer writes a line another way, it gets no delivery row, no question and no announcement, and the failure is invisible (lemma-backend/app/modules/channels/services/posting.py)
  4. A question is claimed before the asker takes the answer — Without the claim and its commit, a tap on a platform and an answer in the app both reach the asking side, and a run resumes twice or a form is submitted twice (lemma-backend/app/modules/channels/services/questions.py)
  5. Every send outside Lemma is a row, retried with backoff until it is given up — The row is the only record of a send (lemma-backend/app/modules/channels/infrastructure/deliveries.py)

Findings · 3 open · 3 settled

  • A run paused when this deploys gets no line waiting for its member — lemma-backend/app/modules/agent/services/pause_questions.py:203
  • 0058 is edited in place, so an environment that already applied it gets none of the new tables — lemma-backend/migrations/versions/2026-10-17_channels_0058.py:31
  • Dropping the notifications table deletes every question that was still waiting on a member — lemma-backend/migrations/versions/2026-10-17_channels_0058.py:483
Worth fixing · 3 warnings

1. A run paused when this deploys gets no line waiting for its member — lemma-backend/app/modules/agent/services/pause_questions.py:203-228

sync_pause_questions is what turns a paused ask_user or request_approval into a channel line with a question row, and the only thing that calls it is the agent.pause_questions handler subscribed to AgentRunFinished (agent/events/channel_question_work.py:113-119). A run that is already paused at deploy has finished nothing since, so no channel_questions row is ever written for it and the member's inbox -- waiting_on (infrastructure/questions.py:241) and the waiting count (infrastructure/inbox.py) -- shows nothing. They can still answer from the conversation's own card, but the new inbox loses it.

Suggested fix:

On deploy, run the sync for every conversation with an undecided pause, or enqueue AGENT_PAUSE_QUESTIONS per paused run as part of the release.

2. 0058 is edited in place, so an environment that already applied it gets none of the new tables — lemma-backend/migrations/versions/2026-10-17_channels_0058.py:31-32

The revision id is unchanged from main -- revision = "0058_channels", down_revision = "0057_contacts_for_everyone" -- while the DDL inside it is rewritten: the old 0058 created the channel tables and kept notifications; this one drops notifications and the three agent_surface_* tables and creates channel_members, channel_read_state, channel_sources, channel_mentions, channel_questions and channel_message_deliveries. Alembic decides by revision id, so any database that already ran 0058 is considered current and none of this runs there: notifications stays, the new tables never appear, and the first request that touches channel_questions or channel_message_deliveries fails. Check alembic current on every environment before merging.

Suggested fix:

If any environment is at 0058_channels, add the new DDL as a new revision on top (0059) instead of editing 0058, or stamp those databases back to 0057 and re-run.

3. Dropping the notifications table deletes every question that was still waiting on a member — lemma-backend/migrations/versions/2026-10-17_channels_0058.py:483

upgrade() runs DELETE FROM work_items WHERE kind = 'notification.expire' and then op.drop_table("notifications") before creating the channel tables. Every row there is an ask, an approval or a form that a member has not answered -- the docstring calls them "what the app shows as waiting on them". Nothing reads them into channel_questions, and downgrade() recreates the table empty, so those rows are gone for good. The docstring says groups and web chat were unreleased and nothing is carried over; it does not say the same for notifications.

Suggested fix:

Before the drop, insert one channel line and `channel_questions` row per open notification (its recipient, kind, body, expiry), or confirm in writing that no environment holds open notifications.
  • The Channels tab is always shown but its pane is behind a flag, so it opens an empty page — fixed
  • One unsubscribed email address silences every address that contact has — fixed
  • Two answers racing can both reach the asking side before either closes the line — fixed
Rounds
# When Commit What Verdict
1 18 Oct, 16:22 UTC 1550cd9 full read Changes needed
2 19 Oct, 00:45 UTC 667ae8f new commits did not finish
3 19 Oct, 01:00 UTC 00e9311 full read Needs a person's review
4 19 Oct, 07:13 UTC 63d4edb full read Needs a person's review
5 19 Oct, 07:35 UTC 54d6d8f new commits Needs a person's review

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]>
Comment thread lemma-backend/app/modules/agent_surfaces/api/kept_chats.py Dismissed
Comment thread lemma-backend/app/modules/agent_surfaces/api/kept_chats.py
anukuljha and others added 8 commits October 18, 2026 22:22
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
main's only change since this branch's base is #993's squash, whose tree is
#993's branch tip -- already merged here -- so this keeps this branch's tree.
@anukuljha
anukuljha changed the base branch from lemma/channels-2-channels to main October 18, 2026 19:11
…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]>
@anukuljha anukuljha changed the title Channels app: in-app channels, questions as lines, Slack and Teams, the Channels place Channels v2 (1/3): one visibility rule, one write path, delivery and question rows Oct 19, 2026
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]>
@anukuljha
anukuljha merged commit 2699dbf into main Oct 20, 2026
50 of 51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants