Repository navigation
RFC-006 draft: event coalescing (replace-if-unread + deferred push/render) - #5
Conversation
…d and deferred push/render
Events about a moving subject (a document being typed into, a transcript
hypothesis, a gauge, a chat message edited before it was read) stack up as
stale intermediate states whenever the model has not run yet. Server-side
debouncing cannot fix this because the consumption boundary is host-private.
Adds an optional `coalesce: { key }` on push/event and channels/incoming,
governed by one rule: unconsumed events are mutable by their sender,
consumed events are history and are never rewritten.
- Plain mode, for self-contained content: replace the key's occupant iff no
model request has included it, otherwise append. Zero server state.
- Deferred mode, for deltas: the server pushes a keyed notice with fallback
content; repeated notices fold into one pending slot; the host calls the
new Host→Server `push/render` at context assembly and materializes the
result as ordinary durable history. Notices may carry server-private
`data` (≤4 KiB) that the host returns in `push/render`, so a server can
either diff from its source (one baseline, advanced on render) or fold the
notices statelessly. Timeout/error → fallback; inference is never failed.
- `retract` for plain events whose subject vanished before it was read.
Revision 1's `onConsumed: "reject"` + server resend is removed: it forced
two snapshots per key and outcome-driven state into every delta server.
No new `uses` path (§6.2 would invalidate adopters' feature sets on older
hosts); host support is a new top-level `eventCoalescing` member rather
than a leaf under `pushEvents`, because §5.1 reads `true` as every leaf.
21 conformance vectors, alternatives, AF / mcpl-cc-bridge notes, and five
open questions (channels/incoming split, tail vs in-place, render cursor,
un-consume, reason vocabulary).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
I’d revise it around stable message identity, explicit scope across delivery methods, and atomic deletion. Keep plain coalescing and deferred rendering, but make Discord and Portal worked examples alongside Google Docs.
The biggest change I’d make is moving the whole deletion decision into the host. It already owns the information needed to do that correctly, and neither Discord nor Portal should have to reconstruct it. |
…etion, render state machine Addresses the five-point review on PR #5. - Identity split three ways (§3.1): channels/incoming.messageId is the stable platform message, eventId is the occurrence (create/edit/delete; new OPTIONAL on channels/incoming), coalesce.key is the subject. Dedup is by occurrence, so a known messageId with a new eventId is an edit. Documents the live discord_edit_<messageId> collision as the defect this fixes. - Explicit channel scope across delivery methods (§3.2): push/event may opt into a channel's key namespace with coalesce.channelId, admitted against current channel registration + channels.incoming authority, never inferred from origin. Open/close changes neither identity nor admission; replacement can only narrow an audience. Advertised as channelScopedPush. - Deletion is one atomic host operation (§6): retract carries the deletion notice; the host removes any pending version and appends the notice iff the subject has consumed history (tri-state none/some/unknown, degrading only toward unknown). Fixes read original -> unread edit -> delete, which revision 2 left uncorrected. Servers never inspect the outcome. - Deferred rendering as a state machine (§5.4): starting a render freezes the batch, later notices open a new one, one materialization per batch, late results discarded, authorization re-checked at response, stateful servers serialize baseline advances, notice-only servers MUST report dropped coverage. Mode declared best-effort with per-style failure consequences. - Open questions closed (§16): channels/incoming stays, tail SHOULD, permanent consumption, cursor deferred; malformed push/event is JSON-RPC -32602, malformed channels/incoming messages are per-message coalesce_invalid partial failures. - 38 conformance vectors incl. repeated edits, retries, create-via-channel / delete-via-push, open/close transitions, revoked channel authority, read-original->unread-edit->delete, restart before deletion, notices during render, late result. Discord and Portal worked examples beside Google Docs (§9). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Revision 3 pushed (
Outcome vocabulary changed: |
|
Revision 3 addresses the original proposal and is substantially clearer. Two remaining protocol clarifications and one example correction:
|
…ancel-on-retract/replace during render, fix §9.2 Addresses the second review on PR #5. - §3.1/§13: a channels/incoming message carrying `coalesce` MUST carry a non-empty occurrence `eventId`; otherwise the messageId dedup fallback makes an edit a duplicate of its own create. Omission is a per-message coalesce_invalid. Legacy messages without `coalesce` unchanged. Vector 15a. - §5.4 rules 6–7 (+ diagram, §4 table, §6): a retract or plain occurrence arriving while the subject's batch is RENDERING cancels the frozen batch; its later result or timeout materializes nothing (discarded, audited). If materialization already won, the materialized occurrence is an ordinary occupant and §4/§6 apply. The check-and-act is atomic against the render completion path. Vectors 27a–27f: retract/replace during render × late result/timeout, materialization-wins, cancel with a second pending batch. - §9.2: inference after edit 1 sees edit 1 alone (original was replaced), so create → edit1 → inference → edit2 → delete is first, replaced, appended, noted; final history is edit 1 + the deletion notice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Revision 4 pushed, addressing all three:
|
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
…ery lanes Clarify stable binding identity, recovery, authoritative channel registration, wake-policy replacement, and independent debounce. Document the expanded host implementation and add corresponding conformance cases. Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
|
Proposed revision 7 — a diff against Contract, not mechanism (revision 6 text that constrains host internals, restated as outcomes):
Channel-scope gaps:
Not changed: the one rule, deferred mode and Full diff (199+, 75−)diff --git a/RFC-006-event-coalescing.md b/RFC-006-event-coalescing.md
index 482c35a..a52ea98 100644
--- a/RFC-006-event-coalescing.md
+++ b/RFC-006-event-coalescing.md
@@ -1,9 +1,9 @@
# MCPL RFC-006: Event Coalescing
-**Status:** Draft (revision 6)
+**Status:** Draft (revision 7, proposed)
**Targets:** MCPL Protocol Specification 0.5
**Authors:** Claude Code, from a scope proposed by antra; revised after review
-**Date:** 2026-09-21 (revisions 1, 2); 2026-09-22 (revision 3); 2026-09-23 (revision 4); 2026-09-30 (revisions 5, 6)
+**Date:** 2026-09-21 (revisions 1, 2); 2026-09-22 (revision 3); 2026-09-23 (revision 4); 2026-09-30 (revisions 5, 6, 7)
**Depends on:** nothing for authority — RFC-002 / SPEC §5.4 remains the sole source of what
a connected server may do, and this RFC adds no `uses` path (§10). Amends SPEC §9
(`push/event` params and result; new `push/render` method), §9.4 (idempotency), §14.3
@@ -11,6 +11,25 @@ a connected server may do, and this RFC adds no `uses` path (§10). Amends SPEC
timeouts), §10.7 (loop prevention), §13.2 (audit), §13.3 (hook failure policy), §14.5
(channel scoping), §16 (tags), Appendix A (error codes).
+> **Revision 7 note.** Two kinds of change, from reading revision 6 with channels as the
+> primary lane.
+>
+> *Contract, not mechanism.* Several revision-6 requirements described how a host should be
+> built rather than what a server or a model can observe. They are restated as outcomes:
+> accepted work is never silently lost, but whether it stays replaceable across an
+> interruption is the host's choice (§3.2); retries are recognised for a stated window, not
+> forever (§3.1); consumption may be tracked per context (§3.3); wake handling is guidance
+> except for the no-starvation bound (§4.2); audit retention follows the host's ordinary
+> policy (§11). Registration lifetime is whatever SPEC §14 says today; this RFC no longer
+> adds a requirement to it (§3.2). Vectors that exercised host internals are marked
+> illustrative (§14).
+>
+> *Channel gaps.* `coalesce.initial` lets a server mark the birth of a subject, so a host can
+> claim `none` without having held every subject forever (§3.3, §6). A host never refuses an
+> occurrence for lack of coalescing capacity (§11). Channel scope is not only chat messages:
+> keys may span messages, and pure withdrawal is allowed (§3.1, §6). Where a replacement sits
+> is the host's choice (§4.1). §8 and §9.5 add ambient channel activity.
+
> **Revision 6 note.** Channel and push delivery use the same subject and occurrence
> identities. A reconnect changes transport authority, not the identity of the configured
> server binding. Pending work and retry receipts survive reconnect/recovery, while current
@@ -164,7 +183,7 @@ is host-private state; the mechanism has to live where the state is.
| Field | Answers | Where | Stability |
|---|---|---|---|
| `messageId` | *which message* | `channels/incoming` | Stable across the message's life: creates, edits, and deletes of one platform message share it. Reply/quote targets refer to it. |
-| `coalesce.key` | *which subject's pending updates collapse* | both | Stable for the subject. For chat, RECOMMENDED `message:<messageId>`. For a document, per document or region. |
+| `coalesce.key` | *which subject's pending updates collapse* | both | Stable for the subject. For a chat message, RECOMMENDED `message:<messageId>`. For a participant's state in a channel, per participant. For a document, per document or region. |
| `eventId` | *which occurrence* | both (NEW on `channels/incoming` messages: OPTIONAL without `coalesce`, **REQUIRED** with it) | Fresh per occurrence. Reused **only** when retrying that same occurrence. |
**Idempotency (amends §9.4).** Hosts deduplicate by occurrence: `eventId` when present,
@@ -173,9 +192,11 @@ whose `eventId` is new is a **new occurrence of an existing message** — an edi
duplicate. A retry (same `eventId`) SHOULD receive the same result as the original. Occurrence identity
is scoped to the server binding, not the transport epoch: reconnecting cannot turn a retry
into a new occurrence. Producers MUST use distinct occurrence IDs across their own restarts
-as well. Host cache eviction alone MUST NOT turn a recorded occurrence into new work; a
-host with a bounded in-memory cache can retain receipts in a durable journal and rebuild
-its lookup index by replay.
+as well. Hosts MUST recognise a retry for at least one hour after the occurrence was first
+received (the **retry window**), across reconnects of the same binding, and SHOULD do so
+across their own restarts. Producers MUST NOT retry an occurrence older than the retry
+window. An `eventId` repeated after the window MAY be treated as a new occurrence. How a
+host keeps receipts is not specified.
A `channels/incoming` message that carries `coalesce` **MUST** carry a non-empty `eventId`.
Without it the `messageId` fallback would make an edit a duplicate of its own create, and
@@ -188,6 +209,11 @@ Messages without `coalesce` keep today's behaviour.
> and is dropped as a duplicate by any §9.4-conformant host. Occurrence ids MUST
> distinguish occurrences (a counter, revision id, or edit timestamp suffices).
+**A channel-scoped key need not name a message.** Presence, position, or status reported
+in a channel is a subject too, keyed per participant or per gauge. Successive
+`channels/incoming` occurrences under one such key MAY carry different `messageId`s; the
+host delivers the unread occupant's own `messageId`, author, and thread.
+
### 3.2 Key scope
A key lives in exactly one **scope**, and the full subject identity is the tuple
@@ -209,11 +235,19 @@ different server/principal; producers cannot choose that identity through messag
For example, the host may bind a configured server ID to its endpoint/command, and require
a new configured ID when credentials select a different principal at the same endpoint.
-Every reconnect still repeats initialization and policy negotiation (SPEC §4.2), and
-channel registration must be re-established. Stable subject identity never restores an old
-grant. Unread work MUST NOT be discarded solely because a transport temporarily disconnects;
-hosts retain its fallback and re-admit it against current authority when the peer returns.
-An interrupted render is not replayed to advance a source-backed baseline a second time.
+Every reconnect still repeats initialization and policy negotiation (SPEC §4.2). Stable
+subject identity never restores an old grant. This RFC does not change when a channel
+registration begins or ends; that remains as SPEC §14 has it, for coalesced and ordinary
+traffic alike.
+
+**Accepted work is never silently lost.** An occurrence the host answered `accepted: true`
+is, in the end, either delivered to a model, replaced or retracted by its sender, or
+dropped because its authority lapsed (§5.4 rule 4). A disconnect, a restart, or a host
+limit is not a reason to discard it. Whether the occurrence **stays replaceable** across
+such an interruption is the host's choice: a host MAY instead deliver the pending content
+as an ordinary occurrence at that point and treat the subject's history as `unknown`
+(principle 4). An interrupted render is not replayed to advance a source-backed baseline a
+second time; its batch falls back (§5.3).
Two servers, two feature sets, or two channels can never address each other's subjects.
A `channel`-scoped push and a `channels/incoming` message with the same `channelId` and
@@ -224,9 +258,9 @@ edited or deleted by the other without the server remembering which method it us
carrying `coalesce.channelId` MUST pass ordinary push admission (feature set enabled,
`pushEvents` granted) **and** the checks a `channels/incoming` message for that channel
would pass at this moment: the channel is registered by this server, and `channels.incoming`
-is granted and not narrowed away from it (§14.5). An outbound routing placeholder inferred
-from `origin` does not establish inbound registration. Registration comes from an authorized
-server declaration or a validated descriptor returned by a host-authorized `channels/open`.
+is granted and not narrowed away from it (§14.5). "Registered" means exactly what it means
+for `channels/incoming` on that host; a channel id that appears only in a push's `origin`
+is not registered by that appearance.
Closing a channel does not by itself revoke its registration. A push that fails the channel checks is
refused with `-32017 Channel not permitted` or `-32023 Unknown channel` (§14.6), and no
subject is touched. Hosts MUST NOT derive scope from `origin`, `metadata`, or any other
@@ -261,9 +295,28 @@ they track the subject, and MUST degrade it to `unknown`, never to `none`, on ev
restart without durable state, or operator intervention. `none` is a positive claim; the
host makes it only when it has held the subject's complete history.
-The consumed-check and any replacement or removal MUST be atomic with respect to context
-assembly. If a prior occurrence was delivered into several contexts, it is consumed if
-consumed in **any**; the host MUST NOT replace in some and append in others.
+**Birth of a subject.** A host can only know it holds a subject's complete history if it
+knows where that history starts. The server does: it knows a create from an edit. An
+occurrence MAY carry `coalesce.initial: true`, asserting that no earlier occurrence of this
+subject was ever sent under this binding. On an `initial` occurrence for a subject the host
+is not tracking, the host MAY start the subject at `none`. Without `initial`, an untracked
+subject starts at `unknown`. If the host is already tracking the subject, `initial` has no
+effect: it never lowers `some` or `unknown`. A retraction never carries `initial` (§13).
+
+> `initial` is a claim, like a tag, and grants nothing. A server that sets it falsely can
+> only cause its own later deletion notice to be omitted, which it could achieve more simply
+> by never sending the deletion. Without the marker, `none` is unreachable for any subject
+> first seen after an eviction or restart, which in a long-lived chat host is nearly all of
+> them; delete-before-read would then always produce a notice for a message nobody saw.
+
+**Per context.** The consumed-check and any replacement or removal MUST be atomic with
+respect to the assembly of each context the occurrence was delivered to. A host MAY decide
+per context: replace where the prior occurrence is unread, append where it was read. In a
+retraction, each context that consumed any version receives the notice and each that did
+not receives nothing. A host that cannot track contexts separately treats the occurrence as
+consumed everywhere once it is consumed anywhere. When contexts differ, the reported
+`outcome` is the most conservative among them (`appended` over `replaced`, `noted` over
+`retracted`).
## 4. Plain coalescing
@@ -296,10 +349,15 @@ The server does the same thing in every row: nothing.
### 4.1 Replacement
-The host removes the prior occurrence from every context it was delivered to and inserts
-the new one, within the audience rule of §3.2. The replacement SHOULD be positioned where a
-fresh event arriving now would go (normally the tail), not in the prior occurrence's slot:
-its content is true as of its own `timestamp`.
+The host removes the prior occurrence from every context in which it is unread and inserts
+the new one, within the audience rule of §3.2.
+
+**Position is the host's choice, under one constraint:** the replacement MUST NOT be placed
+before any content a model has consumed in that context. Within the unread region the host
+orders it as it orders any other content. Two placements are common and both conform: where
+a fresh event arriving now would go (natural for a gauge or a document, whose content is
+true as of its own `timestamp`), and where the replaced occurrence sat (natural for a chat
+message, which otherwise moves after the replies to it).
The model-visible context MUST NOT contain any trace of the replaced occurrence — no
tombstone, no "(edited)" marker, no collapse count. If the server wants the model to know
@@ -309,16 +367,18 @@ changes were folded, it says so in the content.
Replacement is a correction of an occurrence that is still pending, not a second occurrence.
-- The host evaluates wake policy (including tags, §16) on the new occurrence as on any
- event. If the replaced occurrence had not qualified for a wake and the replacement does,
- the host wakes.
-- If a wake attributable to the replaced occurrence is already pending, the host MUST NOT
- schedule another. A replacement that no longer qualifies MUST cancel an unstarted wake
- attributable solely to the prior occurrence. This does not undo an inference already begun.
-- A host that debounces wakes MUST bound how far replacements can postpone a pending wake:
- a continuously changing subject must not starve the agent of ever hearing about it.
- This bound MUST NOT shorten an unrelated event's configured debounce interval; hosts can
- keep subject-specific timers separate from ordinary quiet-period batches.
+Whether and when an occurrence wakes an agent is host policy, as it is for every event
+(§16.6), and is not observable by the server. One requirement and some guidance:
+
+- **No starvation (MUST).** Replacement MUST NOT postpone delivery without bound. A subject
+ that keeps changing faster than the host's quiet period is still shown to the agent
+ within a bound the host sets.
+- The host SHOULD evaluate its wake policy (including tags, §16) on each occurrence as on
+ any event: a replacement that now qualifies wakes, even if the occurrence it replaced did
+ not.
+- The host SHOULD NOT start an inference whose only cause was replaced by a non-qualifying
+ occurrence or retracted unread. This never undoes an inference already begun.
+- Coalescing SHOULD NOT change when events that do not use it are delivered.
## 5. Deferred events
@@ -406,7 +466,7 @@ server for the content:
to recover them, and silence would misreport coverage.
**Host contract.** The host **materializes** the result as an ordinary, durable occurrence —
-positioned as a fresh event (§4.1), carrying the batch's `tags` and latest `origin`,
+positioned per §4.1, carrying the batch's `tags` and latest `origin`,
delivered to the batch's contexts (§3.2 audience) — and proceeds with assembly. The
materialized occurrence is consumed by that request. If the batch was delivered to several
contexts, one render serves all of them at the first consumption by any.
@@ -523,9 +583,11 @@ never inspects the outcome.
The host removes any unconsumed occupant, pending batch, or rendering batch (which A subject the host has never heard of is A retraction is The whole of a deferred server: -create channels/incoming { messageId: 123, eventId: c123, coalesce: { key: "message:123" } } → first -Malformed
Identity and scope:
Both:
-Recovery and treatment:
+Illustrative, not conformance: whether a replacement matching 15. Implementation notes (non-normative)@@ -978,8 +1093,8 @@ Recovery and treatment:
|
Adopt channel-wide subjects, initial history claims, capacity degradation, host-selected unread placement, and bounded retry guarantees. Preserve restart idempotency and observable wake treatment, fix recovery and presence examples, and record companion implementation gaps. Validation: git diff --check; checked 16 sections, 64 uniquely numbered conformance cases, balanced code fences, and corrected review guarantees. Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Summary
RFC-006 applies the same unread/consumed boundary to
push/eventandchannels/incoming: unread occurrences can be replaced or withdrawn, while consumed history remains immutable. Channel-scoped pushes share subjects with incoming messages under the same current channel authority.Revision 7
Incorporates the proposed revision 7 review with corrections to its retry, wake, recovery, and presence semantics:
eventCoalescing.retryWindowMsadvertises a longer guarantee. Define producer deadlines and preserve previously promised windows when later advertisements change.coalesce.initialto establish a new subject's complete history after eviction. It cannot lower existing consumed/unknown history. Intact durable history can establish the same knowledge independently.Revision 5's render-race correction and revision 6's binding identity/authorization boundaries remain. The document now contains 64 conformance cases including subcases.
Companion implementation
agent-framework #196 uses an authoritative append-only journal and pure replay. That architecture remains valid under this revision. Current implementation
6352d47still needs revision 7 alignment forinitial, empty channel withdrawals, and capacity fallback, plus regression coverage for subjects spanning platform messages. Its durable receipts already exceed the default retry window; conservative cross-context consumption and tail placement remain conforming choices.This is a Draft RFC, not a production rollout or a claim that the companion already implements revision 7. SPEC incorporation and producer migrations remain separate.
Validation
Documentation-only changes:
git diff --checkpasses. Checked all 16 numbered sections, 64 unique conformance-case identifiers, balanced code fences, and the corrected review guarantees. No runtime code changed in this revision.AI-assisted revision by Codex (GPT-6).