Skip to content

Channels v2 (2/3): surfaces as transport, one outbound path, contacts policy - #1001

Merged
anukuljha merged 4 commits into
lemma/channels-3-appfrom
lemma/channels-v2-2
Oct 20, 2026
Merged

anukuljha merged 4 commits into
lemma/channels-3-appfrom
lemma/channels-v2-2

Conversation

@anukuljha

Copy link
Copy Markdown
Contributor

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 main once #1000 merges. It edits 0057 and 0058 in place, since neither has been released.

What changes

agent_surfaces becomes transport only. Every outbound send is a delivery row, the agent's answers included. The outreach policy moves into contacts, and what is heard reads as people and links rather than markup.

One outbound path

  • Agent answers are lines first, then deliveries. A finished run's answer, or the failure notice for a failed run, is posted as the agent's line with client_message_id=run:{run_id} and deliver_to set to its place. The delivery executor then sends it.
    • Held files ride the answer, so email still folds into one reply.
    • A streamed Slack or Teams answer is finished in place, and its delivery is recorded SENT with the stream's message id. It is not posted a second time.
    • Web chat answers are SENT at once.
    • A run finished twice is one line and one send.
  • Platform ids on every delivery. Each adapter's send_message returns the platform's id: the Slack ts, 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.
    • Resend sends carry a per-delivery Idempotency-Key, so a retried delivery is the same email.
  • Late failures by id. A WhatsApp status failure (131047, 131026, …) and a Resend email.bounced (permanent), email.complained or email.failed each mark the delivery DEAD by its platform id.
    • A permanent bounce or a complaint on a line to a contact also suppresses that address in the pod.
    • The Redis wamid-to-line map is deleted.
    • A temporary bounce, or an email that is not one of the channels', changes nothing.
  • Send, then crash. A delivery claimed but never finished is not sent again. It becomes SENT when its platform id was noted before the call, otherwise DEAD (INTERRUPTED), which a member can retry. Each send is bounded at 60 s, under a 90 s claim lease.
  • A place whose bot was removed is SKIPPED at once instead of retried.

Contacts policy (contacts.contracts.outreach)

  • may_send(pod, contact, to, kind, at, window) returns a Verdict: allowed, plus a code, reason, retryable flag, footer and unsubscribe token. The order is suppression first, then whether a handle exists, then the window.
    • The per-contact daily count runs in Redis, is idempotent per line, and is counted once per line. Redis being unavailable is retryable, never final.
  • Opt-out first, on every path. STOP is checked before any keeper, answer-policy or route check. It was being dropped silently for a contact with no keeper.
    • A whole-message STOP, an email whose whole subject is "unsubscribe", the email link, the one-click POST (RFC 8058), a permanent bounce or a spam complaint each write a contact_suppressions row. The row is keyed by the hashed handle, so it survives the contact being forgotten.
    • A suppression is lifted only by START or a message the person typed. Auto-replies (Auto-Submitted, out of office, Precedence: bulk) and unauthenticated mail never lift it.
  • Normalised handles.
    • Phones are stored in E.164 via phonenumbers (contacts_default_phone_region).
    • Email addresses are trimmed and lower-cased.
    • Host identities are 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).
  • Email to a contact carries List-Unsubscribe (https and mailto) and List-Unsubscribe-Post: List-Unsubscribe=One-Click.
  • The WhatsApp 24-hour window stays a fact agent_surfaces supplies, read from the place's last_inbound_at: Meta keeps the window per business number and customer.

Ingest, threads and the contact inbox

  • Markup becomes parts. The following become @Name mention parts and link parts:

    • Slack <@U…>, <url|label> and <#C|name>;
    • Teams <at> entities and <a href> links;
    • Telegram text_mention and text_link entities;
    • WhatsApp @<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.

    • Only people who may see the channel get an unread mention.
  • 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 through may_send(ACK).

  • Member-first outreach. POST /pods/{pod_id}/contacts/{contact_id}/outreach {handle, kind?, text, client_message_id?} writes to a contact first.

    • Email works any time and opens a place if needed. WhatsApp and Telegram only work at a place the contact wrote from, within its window.
    • The answer is the line and its delivery. A refusal is 409 when the address is suppressed, 422 outside the window, and 503 when Redis is unavailable.
    • Clients: SDKs PodContacts.outreach and ContactsNamespace.outreach; CLI lemma 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_surfaces is 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

  • STOP from a contact with no keeper was dropped silently, because the opt-out check ran after the keeper and route checks.
  • apply_platform_update never committed when a lifecycle event was handled first, so WhatsApp status failures were not persisted.
  • Every top-level member line to a Slack channel ended FAILED: at_place_level set external_thread_id=None, which failed validation.
  • An explicitly assigned contact was handed back on the contact's next message, because assign() cleared assignee_last_line_at.
  • The committed lemma-teams.zip had none of the RSC permissions, and make teams-manifest-zip called a script that doesn't exist.
  • Every group-log line stored the platform's whole raw payload as its reply address. It now stores the event without raw_payload, which no reply reads.

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        # 11674 passed, 153 skipped
uv run pytest app/modules/agent_surfaces/tests/e2e 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 \
  app/modules/agent/tests/e2e/test_wait_work_e2e.py app/modules/pod/tests \
  -n 4 -q -m "not real_llm and not provider"   # 840 passed

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 delivery DEAD and the next line is SKIPPED; a temporary bounce or an unknown email changes nothing;
  • contacts/tests/e2e/test_outreach_e2e.py:
    • an opt-out holds across handle spellings and outreach kinds, and after the contact is forgotten;
    • opt-outs are scoped per pod;
    • the one-click POST suppresses;
    • the window and the daily count are enforced, and Redis being unavailable is retryable;
  • 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;
  • nine new STOP / START / auto-reply cases in test_contact_door_e2e.py.

Checklist

  • Tests cover the behavioral change
  • Lint, typecheck, and architecture gates pass locally
  • Migrations:
    • 0057 adds contact_identities.issuer and value_raw, rewrites stored phones to E.164 and host values to host:default:…, moves unsubscribed_at into contact_suppressions, and adds agent_surface_web_widgets.host_issuer;
    • 0058 adds channels.acknowledged_at and channel_message_deliveries.claimed_at;
    • each has an upgrade and downgrade e2e.
  • API surface: OpenAPI spec, both SDKs and lemma-client.js regenerated (outreach endpoint, host_issuer)
  • Security: webhook events are verified with the Svix signature before anything is read; suppressions are stored hashed; an opt-out can't be lifted by an auto-reply or unauthenticated mail
  • Rollback: revert, then downgrade 0058 and 0057; nothing has been released

Compatibility and rollback notes

  • Configuration: surface_contact_follow_ups_per_contact_per_day is replaced by contacts_member_lines_per_contact_per_day. New: contacts_default_phone_region.
  • Resend: the surfaces webhook (/surfaces/webhooks/resend) now also needs the email.bounced, email.complained and email.failed events. Identity's /auth/email/bounces/resend still deactivates accounts whose auth email bounces. Point Resend at both, or let both share one endpoint secret.
  • Teams: the manifest is now 1.1.0, so an admin has to re-consent for the new RSC permissions.
  • Not in this PR:
    • narration text sent with a question or approval card, and notices such as APPROVED_ACTION_FAILED_NOTICE, are no longer written as timeline lines (follow-up);
    • held WhatsApp replies and answers are now separate paths, so their order isn't enforced;
    • the Teams admin re-consent prompt in the product.

🤖 Generated with Claude Code

… 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]>
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 97604175-fcf5-4c66-8d1e-341615c00942

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

CodeQL

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

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.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Module Unit E2E only Combined
Overall 73.2% 82.2% 91.5%
agent 78.7% 83.3% 92.8%
agent_surfaces 73.0% 82.9% 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.3% 92.5%
pod 62.3% 89.6% 92.1%
pod_bundle 75.8% 69.5% 88.2%
schedule 63.5% 89.0% 91.7%
usage 74.4% 84.5% 91.9%
workflow 70.1% 82.9% 89.8%
workspace 79.4% 59.4% 86.8%

@deepak-jha-kgp

deepak-jha-kgp commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Factory review: Changes needed

round 2 · reviewed at a0af76d · updated 20 Oct, 04:44 UTC · waiting on @anukuljha · high risk

Every line that leaves a pod takes one path out

  • Before: Each service that sent a line to a place or to a member planned its own delivery row, decided its own rules for writing to a contact, and recorded the outcome its own way: contact_rules.py held one caller's version of those rules, and PlatformDeliverySender posted whatever it was handed.
  • After: A line's send is one delivery row, planned before anything is sent, claimed under a lease by one worker and settled with an outcome on that row; every write to a contact passes one policy first — opt-out, then a handle, then the platform's window — with the contact's day counted once in Redis however often a send is retried.

What could break

  • If any environment has already run revision 0058_channels (main carries it from Channels: one timeline per place; one conversation per person and agent #993), then editing the file in place means Alembic never re-runs it, so channel_cases, channel_case_events, channels.current_case_id and external_accounts.workspace_key never appear — and every channels request fails on them. (medium likelihood, high impact; guard: none — no test upgrades a database that already ran main's 0058)
  • If a contact opted out before the migration and their handle was stored with a leading 00, a space or a bracket, then the suppression row 0057 writes is keyed by the hash of that spelling while may_send looks it up by the hash of the normalised handle, so the pod writes to somebody who asked it to stop. (medium likelihood, high impact; guard: none — the unit tests cover decide() and may_send() with already-normalised handles, not the migration)
  • If a sender dies between claiming a row and recording the outcome, the row is settled DEAD with INTERRUPTED and is never sent again blind: the message may be on the platform already, or may not, and only a member retrying the row resolves it. (medium likelihood, medium impact; guard: DeliveryRepository.interrupted() and the reclaim path for platforms that dedupe on the delivery's key; test_channel_delivery_races_e2e.py)

299 of 67,793 changed lines worth reading. The rest is mechanical, generated or lockfiles.

Decisions this PR makes

  1. Migrations 0057 and 0058 are edited in place although main already carries both revision ids — Alembic records a revision as applied (lemma-backend/migrations/versions/2026-10-17_channels_0058.py)
  2. The migration hashes the handle as it is stored, while every lookup hashes the normalised handle — An opt-out written by 0057 is keyed by sha256 of the stored spelling; may_send and SuppressionRepository key everything through handle_hash, which normalises first (lemma-backend/migrations/versions/2026-10-17_contacts_for_everyone_0057.py)
  3. Every outbound send is one delivery row claimed under a lease, and a lapsed claim is never retried blind — If a sender dies mid-send the row becomes DEAD/INTERRUPTED rather than being sent again, so a message may be on the platform and marked not delivered, or lost, and only a member can retry it (lemma-backend/app/modules/channels/infrastructure/deliveries.py)
  4. A run's answer closes its live stream in place and reuses that message as the delivery — On Slack the answer has been streaming into one message (lemma-backend/app/modules/agent_surfaces/services/run_answer_sends.py)
  5. All writing to a contact passes one policy in one order, with the day counted in Redis — Opt-out first (lemma-backend/app/modules/contacts/domain/outreach.py)

Findings · 2 open · 1 settled

  • 0057 keys each opt-out by the stored spelling, but every lookup keys it by the normalised handle — lemma-backend/migrations/versions/2026-10-17_contacts_for_everyone_0057.py:83
  • 0058 is edited in place although main already carries revision 0058_channels — lemma-backend/migrations/versions/2026-10-17_channels_0058.py:701
Worth fixing · 2 warnings

1. 0057 keys each opt-out by the stored spelling, but every lookup keys it by the normalised handle — lemma-backend/migrations/versions/2026-10-17_contacts_for_everyone_0057.py:83-93

The migration writes suppressions as sha256(convert_to(value, 'UTF8')) over the value it just rewrote (0057.py:90-93), and only rewrites a phone when it is all digits (value ~ '^[0-9]+$', 0057.py:83-84). Every runtime lookup goes through handle_hash, which normalises first (handles.py:115 → normalize_handle → e164, handles.py:55). So for a handle the migration did not canonicalise the two keys differ and the opt-out is invisible: e164("0044790000123") returns +44790000123 while the migration stores +0044790000123; a value stored as +1 (415) 555-0100 is not touched at all and hashes as its punctuated self, while lookups hash +14155550100. The person who unsubscribed before the migration is written to again by every sender, because may_send (contacts/services/outreach.py:68) and decide (contacts/domain/outreach.py:114) find no suppression for them.

Suggested fix:

Spell the values the way the code spells them before hashing, or hash the way the code hashes: `UPDATE contact_identities SET value = ...` for every phone through the same rule, then `INSERT ... SELECT pod_id, kind, sha256(convert_to(<the normalised value>, 'UTF8')) ...`. A one-line guard test that a migrated row's `value_hash` equals `handle_hash(kind, value)` would pin it.

2. 0058 is edited in place although main already carries revision 0058_channels — lemma-backend/migrations/versions/2026-10-17_channels_0058.py:701-749

origin/main has migrations/versions/2026-10-17_channels_0058.py with revision = "0058_channels" and down_revision = "0057_contacts_for_everyone" (added by #993), and this diff edits the same file under the same revision id (blob 39d1fb9a on main, 2edbc5b8 here). Alembic stores a revision as applied, so any database that already ran main's 0058 keeps the old schema: the new tables channel_cases (0058.py:102) and channel_case_events (0058.py:138), and the new columns channels.current_case_id, channels.last_rev, channels.changed_rev, external_accounts.workspace_key/bot_key and channel_messages.parts, are never created there, and every channels query fails with UndefinedColumn/UndefinedTable. The same applies to 0057, which this diff also edits (blob 5cd35cd0 on main, b3e942f0 here) — its new contact_identities.issuer, value_raw and contact_suppressions rows would never appear on such a database. On the factory's own note: this finding is a repeat of an open one, kept because it is still true at this commit and now larger.

Suggested fix:

Either add a new revision after main's 0058 that creates the missing tables and columns, or confirm in the PR body that no environment has run 0058_channels and 0057_contacts_for_everyone (and delete the old rows from alembic_version if one has).
  • A retried outreach call that names a different handle unpacks an empty list and answers 500 — fixed
Rounds
# When Commit What Verdict
1 19 Oct, 09:17 UTC fdde613 full read Needs a person's review
2 20 Oct, 04:44 UTC a0af76d full read Changes needed

One comment, edited after every round. Last updated 20 Oct, 04:44 UTC.

anukuljha and others added 3 commits October 20, 2026 09:39
…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]>
@anukuljha
anukuljha merged commit eb86dd6 into lemma/channels-3-app Oct 20, 2026
50 checks passed
anukuljha added a commit that referenced this pull request Oct 20, 2026
…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]>
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.

2 participants