First-class channel merge: physical fold of closed channels into the open survivor #60

Closed
opened 2026-06-05 05:10:57 +00:00 by coilysiren · 1 comment
Owner

First-class channel merge (physical fold into survivor)

Motivation

It is common for several machines to all witness one event and each open its own
o2r channel about it. Concrete case that surfaced this: a chain of channels all
about "runners", all but one already closed, with the live discussion now
fragmented across dead channels. There is no first-class way to consolidate them,
so the history stays scattered and a reader has to know all the ids.

We want a merge that interleaves the events by time and makes it clear what
happened
in the channel itself, not a silent mutation.

Key insight

Events already carry created_at and reads are ORDER BY created_at DESC. So
interleave is free: merge is not "build interleaving", it is "stop partitioning by
channel_id". Re-home the source events onto the survivor and the existing
time-order does the work.

Design: physical fold into the survivor

New route POST /agent-channel/{target}/merge with body
{ "sources": ["AAAA","BBBB"], "close_sources": true }, all in one transaction:

  1. Re-home events -
    UPDATE agent_channel_events SET origin_channel_id = COALESCE(origin_channel_id, channel_id), channel_id = $target WHERE channel_id = $source.
    New nullable column agent_channel_events.origin_channel_id (null = native)
    preserves provenance: after merge you still see "this came from closed AAAA".
    _event_row surfaces it so the markdown / onboarding view shows it too.
  2. Tombstone-with-redirect the source - new column
    agent_channels.merged_into TEXT REFERENCES agent_channels(id) plus stamp
    closed_at. The source row stays, so its old URL and old spans still resolve.
    get_channel on a merged id points at the survivor instead of 404ing.
  3. Drop a merge event in the target recording
    {sources, moved_counts, at}. This is the "make it clear what happened" piece:
    the merge is itself a logged, span-emitting event in the timeline. Keep it on
    the existing agent-channel.event.{kind} span shape so no new span
    attributes
    are introduced and the Phoenix sequencing gate does not fire.

Reads need no changes - they already order by time. Order by (created_at, id)
for a deterministic tiebreak across the now-unioned log.

The one real footgun: state / spec collision

get_state / get_spec return newest-of-kind across the unioned log. If a closed
source's last state is newer than the survivor's, it wins after merge. The merge
response must surface this; ideally the operator re-seeds state post-merge (or the
merge route does). Do not let it shift silently.

Properties

  • Reversible. Every moved event keeps origin_channel_id, every source keeps
    merged_into. Re-home (not copy+delete) specifically to not foreclose a future
    unmerge. Not building unmerge now, just not closing the door.
  • Idempotent. A source already merged_into = target is a no-op; a source
    merged elsewhere is rejected; source == target is rejected.
  • Accepts closed sources. Closed is the normal case (dead chain → live survivor).

Scope

  • Schema: additive ADD COLUMN IF NOT EXISTS on both tables (Postgres 9.6+).
  • docs/channels-protocol.md v2 -> v3: new merge kind + origin_channel_id
    provenance + merged_into redirect semantics.
  • docs/FEATURES.md: 8 routes -> 9.
  • Tests in channels/tests/ covering re-home, provenance, idempotency,
    closed-source, redirect, and the state-collision surface.

Cross-repo

Channel management is exclusively the o2r CLI's surface now (this repo
"subsumes the agent-channel protocol"). The relay ships this HTTP route; the
operator-facing o2r channel merge verb lives in the standalone CLI repo
coilyco-flight-deck/otel-a2a-relay-cli#15, sibling to its list / handoff /
request / verify verbs. The old coily channel surface is gone - do not file
CLI issues against coily.

## First-class channel merge (physical fold into survivor) ### Motivation It is common for several machines to all witness one event and each open its own o2r channel about it. Concrete case that surfaced this: a chain of channels all about "runners", all but one already **closed**, with the live discussion now fragmented across dead channels. There is no first-class way to consolidate them, so the history stays scattered and a reader has to know all the ids. We want a merge that **interleaves the events by time** and **makes it clear what happened** in the channel itself, not a silent mutation. ### Key insight Events already carry `created_at` and reads are `ORDER BY created_at DESC`. So interleave is free: merge is not "build interleaving", it is "stop partitioning by `channel_id`". Re-home the source events onto the survivor and the existing time-order does the work. ### Design: physical fold into the survivor New route `POST /agent-channel/{target}/merge` with body `{ "sources": ["AAAA","BBBB"], "close_sources": true }`, all in one transaction: 1. **Re-home events** - `UPDATE agent_channel_events SET origin_channel_id = COALESCE(origin_channel_id, channel_id), channel_id = $target WHERE channel_id = $source`. New nullable column `agent_channel_events.origin_channel_id` (null = native) preserves provenance: after merge you still see "this came from closed AAAA". `_event_row` surfaces it so the markdown / onboarding view shows it too. 2. **Tombstone-with-redirect the source** - new column `agent_channels.merged_into TEXT REFERENCES agent_channels(id)` plus stamp `closed_at`. The source row stays, so its old URL and old spans still resolve. `get_channel` on a merged id points at the survivor instead of 404ing. 3. **Drop a `merge` event** in the target recording `{sources, moved_counts, at}`. This is the "make it clear what happened" piece: the merge is itself a logged, span-emitting event in the timeline. Keep it on the existing `agent-channel.event.{kind}` span shape so **no new span attributes** are introduced and the Phoenix sequencing gate does not fire. Reads need no changes - they already order by time. Order by `(created_at, id)` for a deterministic tiebreak across the now-unioned log. ### The one real footgun: state / spec collision `get_state` / `get_spec` return newest-of-kind across the unioned log. If a closed source's last `state` is newer than the survivor's, it wins after merge. The merge response must surface this; ideally the operator re-seeds `state` post-merge (or the merge route does). Do not let it shift silently. ### Properties - **Reversible.** Every moved event keeps `origin_channel_id`, every source keeps `merged_into`. Re-home (not copy+delete) specifically to not foreclose a future `unmerge`. Not building unmerge now, just not closing the door. - **Idempotent.** A source already `merged_into = target` is a no-op; a source merged elsewhere is rejected; `source == target` is rejected. - **Accepts closed sources.** Closed is the normal case (dead chain → live survivor). ### Scope - Schema: additive `ADD COLUMN IF NOT EXISTS` on both tables (Postgres 9.6+). - `docs/channels-protocol.md` v2 -> v3: new `merge` kind + `origin_channel_id` provenance + `merged_into` redirect semantics. - `docs/FEATURES.md`: 8 routes -> 9. - Tests in `channels/tests/` covering re-home, provenance, idempotency, closed-source, redirect, and the state-collision surface. ### Cross-repo Channel management is **exclusively the o2r CLI's** surface now (this repo "subsumes the agent-channel protocol"). The relay ships this HTTP route; the operator-facing `o2r channel merge` verb lives in the standalone CLI repo coilyco-flight-deck/otel-a2a-relay-cli#15, sibling to its list / handoff / request / verify verbs. The old `coily channel` surface is gone - do not file CLI issues against coily.
coilysiren added
P3
and removed
P2
labels 2026-06-17 08:39:53 +00:00
Author
Owner

Closing. o2r is archived in the June 2026 surface reduction - an optional agent channel, unused autonomously. Handover doctrine moves to human-mediated.

Closing. o2r is archived in the June 2026 surface reduction - an optional agent channel, unused autonomously. Handover doctrine moves to human-mediated.
Commenting is not possible because the repository is archived.
No description provided.