Skip to content

RFC-006 draft: event coalescing (replace-if-unread + deferred push/render) - #5

Merged
antra-tess merged 8 commits into
mainfrom
rfc-006-event-coalescing
Oct 1, 2026
Merged

antra-tess merged 8 commits into
mainfrom
rfc-006-event-coalescing

Conversation

@antra-tess

@antra-tess antra-tess commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

RFC-006 applies the same unread/consumed boundary to push/event and channels/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:

  • Specify observable outcomes without requiring a journal, snapshot, index, timer layout, or audit-retention architecture. Accepted work survives interruption, although a host may seal it for ordinary delivery instead of retaining replaceability. Registration lifetime remains governed by SPEC §14.
  • Guarantee idempotent coalesced retries for at least one hour, including across host restarts. Optional eventCoalescing.retryWindowMs advertises a longer guarantee. Define producer deadlines and preserve previously promised windows when later advertisements change.
  • Permit coalesce.initial to 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.
  • Permit non-message channel subjects and empty withdrawal; recommend correction notices for readers who saw the old state. The ambient-activity example sends a departure notice after presence was consumed and uses distinct visit keys.
  • Degrade to ordinary delivery when coalescing slots are exhausted, subject to normal admission/backpressure. Deferred overflow delivers the admitted fallback without rendering.
  • Allow placement within unread content and optional per-context decisions. Preserve shared deferred-render baselines in every recipient context so later deltas retain their prerequisites.
  • Keep wake eligibility, cancellation of a displaced sole cause, and unrelated debounce timing as behavioral guarantees. No particular timer structure is required. No-starvation applies to eligible work and does not override skip/pause policy.
  • Correct the recovery vector to allow E2 alone when E1 remained replaceable, or E1 then E2 when recovery sealed E1. Retain existing wake vector numbers and add restart-window and shared-baseline cases.

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 6352d47 still needs revision 7 alignment for initial, 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 --check passes. 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).

…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>
@antra-tess

Copy link
Copy Markdown
Contributor Author

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.

  1. Separate the message’s identity from each update’s identity (§3).

    The draft currently requires a fresh eventId / messageId per update. I’d preserve channels/incoming.messageId as the stable platform message ID and add an eventId for individual creates, edits, and deletions.

    • messageId: which message.
    • coalesce.key: which subject’s pending updates collapse.
    • eventId: which occurrence, reused only when retrying that occurrence.

    This preserves reply targets while allowing repeated edits through deduplication.

  2. Add an explicitly authorized channel scope for chat pushes (§3, §9).

    Keep the existing feature-set scope for ordinary pushes. Allow a chat push to opt into a channel’s coalescing namespace:

    {
      "eventId": "update-456",
      "coalesce": {
        "channelId": "discord:general",
        "key": "message:123"
      }
    }

    channels/incoming uses its existing channelId. Both then address the same tuple:

    (server connection, channel scope, channelId, key)
    

    Normative constraints:

    • A channel-scoped push must pass ordinary push admission and current channel registration/channels.incoming authorization.
    • Never infer scope from untrusted origin fields.
    • Replacement updates the original pending deliveries; matching a key cannot expand their audience.
    • Channel open/close changes do not change subject identity or bypass current admission policy.
    • Hosts advertise support for channel-scoped pushes explicitly.

    This accommodates the integrations’ existing routing without requiring servers to remember which method originally delivered every message.

  3. Make deletion one atomic host operation (§6).

    I’d replace the “retract, inspect outcome, possibly send deletion” sequence with retract plus conditional fallback content:

    {
      "coalesce": {
        "channelId": "discord:general",
        "key": "message:123",
        "retract": true
      },
      "payload": {
        "content": [
          { "type": "text", "text": "Message 123 was deleted." }
        ]
      }
    }

    The host removes any replaceable pending version, then decides whether to materialize the deletion notice:

    History of this subject Model-visible result
    Known never consumed Remove pending content; append nothing
    Any version consumed Preserve consumed history; append deletion notice
    History uncertain after restart/eviction Append deletion notice conservatively

    The check must cover earlier versions, not just the current occupant. Consider:

    original read → edit pending → deletion
    

    Today’s draft retracts the edit and reports success, leaving the read original uncorrected. The revised rule removes the edit and appends the deletion.

    Hosts therefore track whether a subject has consumed history; eviction turns that knowledge into “unknown,” never “known unread.” For chat deletions, fallback content should be required. Pure withdrawal without a notice can remain available for other uses.

    This also makes server behavior simpler: one request, no outcome-dependent follow-up.

  4. Specify deferred rendering as a state machine (§5).

    Add these rules:

    • Starting a render freezes that batch of notices.
    • Later notices enter a new pending batch.
    • Completing the render closes only its frozen batch.
    • Timeout materializes fallback; a late response cannot replace it.
    • Stateful servers serialize baseline changes per key, including work continuing after a host timeout.
    • Hosts recheck authorization when the response arrives.

    Keep the cursor optional for now. Explicitly describe deferred rendering as best-effort. Notice-only consumers must report missing coverage when notices were dropped; rereading a document and folding an unrecoverable webhook stream have different failure consequences.

  5. Replace the open questions with decisions and targeted conformance cases.

    Keep channels/incoming, tail positioning as SHOULD, and permanent consumption. Align malformed push requests with JSON-RPC errors, while explicitly defining partial failures for channel batches.

    Add cases for repeated edits, retries, create-via-channel/delete-via-push, open/close transitions, revoked channel authority, read original → unread edit → delete, restart before deletion, and notices arriving during render.

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>
@antra-tess

Copy link
Copy Markdown
Contributor Author

Revision 3 pushed (RFC-006 head), addressing all five points:

  1. Identity split (§3.1) — messageId = platform message, eventId = occurrence (now OPTIONAL on channels/incoming), coalesce.key = subject; dedup by occurrence. Checking the code confirmed the motivation: discord-mcpl's eventId: discord_edit_${messageId} means a second edit of one message is dropped as a duplicate by AF's dedup today.
  2. Channel-scoped pushes (§3.2) — coalesce.channelId opts a push into a channel's namespace; admitted against current registration + channels.incoming authority (-32017/-32023 on failure), never from origin; open/close doesn't change identity; audience can only narrow. Advertised as eventCoalescing.channelScopedPush. This matches how discord-mcpl and portal-mcpl actually deliver (creates via either lane, edits/deletes always push/event).
  3. Atomic deletion (§6) — retract carries the notice; host removes pending and appends the notice iff consumed history is some or unknown (tri-state, eviction → unknown). §9.4 walks read original → unread edit → delete. Non-empty notice is MUST for channel-scoped retractions.
  4. Render state machine (§5.4) — freeze on start, later notices open a new batch, one materialization per batch, late result discarded + audited, re-admission at response, per-subject baseline serialization for stateful servers, MUST-report on dropped > 0 for notice-only servers, mode declared best-effort.
  5. Decisions (§16) — channels/incoming stays, tail SHOULD, permanent consumption, cursor deferred; -32602 on push/event, per-message coalesce_invalid on channels/incoming. 38 vectors now, including every case named in the review; Discord and Portal worked examples in §9.

Outcome vocabulary changed: first | replaced | appended | retracted | noted | consumed (noted = deletion notice appended; absent dropped since an unknown subject now yields noted).

@antra-tess

Copy link
Copy Markdown
Contributor Author

Revision 3 addresses the original proposal and is substantially clearer. Two remaining protocol clarifications and one example correction:

  1. Require eventId on coalesced channels/incoming messages (§3.1, §13).

    eventId is currently optional, and deduplication falls back to the stable messageId when it is absent. That permits a coalesced edit to be discarded as a duplicate of the create. Preserve optionality for legacy messages, but require a non-empty occurrence eventId whenever a channel message carries coalesce; retries reuse that ID, while distinct creates/edits/deletions use distinct IDs. Define a per-message validation failure for omission and add a conformance vector.

  2. Define retraction and plain replacement while a render is in flight (§5.4, §6).

    The new state machine specifies what happens when another deferred notice arrives during rendering, but does not explicitly specify a retract or a plain replacement arriving then. For example:

    notice K → render starts → retract K → render result arrives
    

    Removing only the pending batch is insufficient if the frozen render can subsequently materialize the withdrawn content. Specify how these operations interact with the frozen batch and its eventual result or timeout fallback. If a batch is superseded or withdrawn before materialization, its late completion must not resurrect it or overwrite the replacement. If materialization/consumption has already won the race, apply the normal consumed-history rules. The boundary needs to be atomic.

    Add vectors for retract-during-render and plain-replacement-during-render, including a late result and a timeout. This is distinct from the existing late-result-after-fallback vector.

  3. Correct the Discord worked example (§9.2).

    If the agent runs after edit 1, the original has already been replaced, so the model sees edit 1 alone. For create → edit 1 → inference → edit 2 → delete, the outcomes should be:

    first, replaced, appended, noted
    

    Edit 2 is then removed unread, and the final model-visible history contains edit 1 followed by the deletion notice. The current prose and outcome sequence describe a different timeline.

…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>
@antra-tess

Copy link
Copy Markdown
Contributor Author

Revision 4 pushed, addressing all three:

  1. eventId REQUIRED with coalesce on channels/incoming (§3.1, §13). Optional stays for legacy messages; omission with coalesce → per-message coalesce_invalid, subject untouched, siblings processed. Vector 15a.
  2. Retract / plain replacement during render (§5.4 rules 6–7, diagram, §4 table, §6). A retract or plain occurrence arriving while the batch is RENDERING cancels the frozen batch: its later result or timeout materializes nothing (result discarded + audited, no fallback). If materialization already won, the materialized occurrence is an ordinary occupant and normal §4/§6 rules apply — including consumed-history for the notice. Check-and-act is atomic against the render completion path; servers see nothing (their response is acknowledged either way). Vectors 27a–27f: retract×{late result, timeout}, replace×{late result, timeout}, materialization-wins (unconsumed and consumed), and cancel with a second pending batch open.
  3. §9.2 corrected: first, replaced, appended, noted; model sees edit 1 alone, then the deletion notice.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
antra-tess and others added 3 commits September 30, 2026 01:30
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>
@antra-tess

Copy link
Copy Markdown
Contributor Author

Proposed revision 7 — a diff against 90a24d4, from reading revision 6 with channels as the primary lane (Discord edits, world/presence churn), and with one test applied throughout: is this observable by a server or a model? If not, it is host mechanism and the RFC should state the outcome instead.

Contract, not mechanism (revision 6 text that constrains host internals, restated as outcomes):

  • §3.2 recovery. "Retain the fallback and re-admit it" → accepted work is never silently lost; whether it stays replaceable across an interruption is the host's choice. A host may deliver pending content as ordinary occurrences and degrade history to unknown (principle 4).
  • §3.2 registration. "Channel registration must be re-established" on reconnect is a new requirement on ordinary traffic that SPEC §14 does not make. Dropped; the RFC adds no registration step. "Outbound routing placeholder" is a host concept and is gone from normative text.
  • §3.1 receipts. "Eviction MUST NOT turn a receipt into new work" requires unbounded storage. Replaced by a retry window (≥ 1 hour) that binds both sides.
  • §3.3 consumption. "Consumed in any means consumed in all" forbids per-context decisions. Now permitted, with the conservative outcome reported.
  • §4.2 wake. Cancellation and separate debounce timers are scheduler policy; only the no-starvation bound stays a MUST.
  • §11 audit. Retention follows the host's audit policy; not a MUST.
  • §14. Vectors 43–44 withdrawn (unobservable); 37 marked illustrative; 39–41 rewritten to test outcomes.

Channel-scope gaps:

  • coalesce.initial (§3.3, §6, §13). The RFC lets a host claim none only after holding a subject's complete history, but nothing on the wire says where that history begins. For per-message keys on a long-lived host, none is therefore unreachable after the first eviction or restart, and delete-before-read always produces a notice for a message nobody saw — §9.2's headline case fails. The server knows a create from an edit; initial: true says so. It is a claim like a tag: a false one can only suppress the sender's own deletion notice, which it could do by not sending the deletion.
  • Capacity never refuses (§11, vector 34c). In a busy channel the number of unread subjects is the backlog. A host at its subject limit degrades to appending, as a host without coalescing would; it must not error on an occurrence it would otherwise accept.
  • Channel scope is not only chat messages (§3.1, §6, vector 33). Keys may span messages (presence, position per participant); empty-content retraction is allowed in channel scope (the chat-notice MUST becomes a SHOULD the host cannot enforce anyway).
  • Positioning (§4.1, §16.2). Tail placement moves an edited message after its replies. The only property anyone can rely on is "not before consumed content"; the RFC now states that constraint and no preference.
  • §8 / §9.5. Rows and a worked example for ambient channel activity: one deferred digest per channel for churn, per-participant plain subjects for state, messages never folded.

Not changed: the one rule, deferred mode and push/render, atomic retraction, the identity split, the render state machine, and the security model.

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.

payload.content is the deletion notice: what the model should see if it ever saw a
-version of the subject
. It MAY be empty for pure withdrawal (a discarded transcript partial
-has nothing to announce). For chat deletions it MUST be non-empty: a model that read a
-message must learn it is gone.
+version of the subject*. It MAY be empty for pure withdrawal (a discarded transcript partial,
+a participant who left a channel and whose presence line has nothing to announce). For a
+deleted chat message it SHOULD be non-empty: a model that read a message should learn it
+is gone. The host cannot tell a message from any other channel-scoped subject, so it does
+not enforce this (§13).

The host removes any unconsumed occupant, pending batch, or rendering batch (which
becomes CANCELLED, §5.4 rule 6) of the subject from every context it was delivered to (no
@@ -544,7 +606,9 @@ the notice. Revision 2 got this wrong.

A subject the host has never heard of is unknown, not none: the host cannot distinguish
"never sent" from "evicted", and the conservative result — the notice appears, as it does
-today — costs one line of context.
+today — costs one line of context. A subject whose first tracked occurrence carried
+initial (§3.3) starts at none, which is what makes "retracted" reachable for a
+message created, edited and deleted after the host last evicted or restarted.

A retraction is accepted: true whenever it passes admission (which, for channel-scoped
retractions, includes §3.2's channel checks). A host MAY cancel a pending wake attributable
@@ -577,7 +641,9 @@ deferred. Subject gone → retract, with a notice if a reader would need one.
|---|---|---|---|
| Battery, presence, pose, spend gauge | plain | featureSet | per gauge |
| Transcript hypothesis for an utterance | plain; retract (empty) for discarded partials | featureSet | per utterance |
-| Chat message: create, edit, delete | plain; retract (with notice) | channel | message:<messageId> |
+| Chat message: create, edit, delete | plain (initial on create); retract (with notice) | channel | message:<messageId> |
+| Participant presence / position / status in a channel | plain; retract (empty) on leave | channel | per participant |
+| Ambient channel activity (movement, emotes, joins) | deferred, one digest per channel | channel | per channel |
| Document edits, comment-thread activity, "N new commits" | deferred | featureSet | per document / thread / repo |

The whole of a deferred server:
@@ -600,7 +666,7 @@ on push/render(key, notices): return summarize(notices.map(n => n.data), drop

on create:  deliver as today (channels/incoming if open, else push/event with coalesce.channelId)
-            + coalesce.key = "message:" + id
+            + coalesce: { key: "message:" + id, initial: true }
on edit:    push { eventId: fresh, coalesce: { channelId, key }, tags: ["chat:edited"],
                   content: edited text }
on delete:  push { eventId: fresh, coalesce: { channelId, key, retract: true }, tags: ["chat:deleted"],
@@ -641,14 +707,17 @@ Channel `discord:general` is **open**. Alice posts, edits twice, then deletes 
the agent runs:

-create channels/incoming { messageId: 123, eventId: c123, coalesce: { key: "message:123" } } → first
+create channels/incoming { messageId: 123, eventId: c123, coalesce: { key: "message:123", initial: true } } → first
edit 1 push/event { eventId: e123a, coalesce: { channelId, key } } → replaced
edit 2 push/event { eventId: e123b, coalesce: { channelId, key } } → replaced
delete push/event { eventId: d123, coalesce: { channelId, key, retract: true },
content: "Message 123 by Alice was deleted." } → retracted


-The model never sees anything: history is `none`. Had the agent run **after edit 1**, the
+The model never sees anything: the create carried `initial`, so history is `none` even if
+the host restarted or evicted this channel's subjects before message 123 existed. Without
+`initial` the same sequence yields `noted` on a host that has ever evicted or restarted,
+and the model sees a deletion notice for a message it never saw. Had the agent run **after edit 1**, the
same four requests yield `first`, `replaced`, `appended`, `noted`: edit 1 had already
replaced the unread original, so the model saw edit 1 alone; edit 2 is appended as a new
unconsumed occupant, then removed unread by the retraction; and the deletion notice lands
@@ -678,6 +747,28 @@ delete   → noted        edit REMOVED (unconsumed); notice APPENDED (history is
Model-visible: original, then "Message 123 by Alice was deleted." Revision 2 produced
original alone, uncorrected.

+### 9.5 Ambient channel activity (deferred, channel scope)
+
+A virtual-world or presence server relays a channel where most traffic is state churn:
+participants move, emote, join and leave. Each change is true only until the next one, and
+the agent needs the current picture when it looks, not the series. The server keeps one
+subject per channel for the churn and one per participant for their state:
+
+```
+t0  Bob moves          → notice { channelId, key: "activity", deferred: true }         → first (wake per policy)
+t1  Bob moves again    → notice { channelId, key: "activity", deferred: true }         → replaced (joins batch)
+t2  Cara joins         → plain  { channelId, key: "presence:cara", initial: true }     → first
+t3  Cara says "hi"     → channels/incoming { messageId: 9, coalesce: { key: "message:9", initial: true } } → first
+t4  agent assembles    ← push/render { channelId, key: "activity" }
+                        → "Bob moved from the fountain to the gate."
+                          (the intermediate position vanished; Cara's presence and greeting
+                           are separate occurrences and are delivered as themselves)
+t5  Cara leaves        → retract { channelId, key: "presence:cara" }, content: []          → consumed
+```
+
+Messages are never folded into the digest: only the churn is. Nothing here requires the
+host to know what a "channel message" is; it sees three subjects in one channel scope.
+
## 10. No new capability

Replacing or withdrawing one's own unread occurrence, or supplying its content later rather
@@ -719,11 +810,12 @@ It is deliberately *not* a leaf under `pushEvents` or `channels`: §5.1 defines
- **Deletion cannot hide a read message.** The consumed-history bit degrades only toward
  `unknown`, which yields the notice. A host that cannot remember whether the model saw
  something behaves as though it did.
-- **Humans and logs are not models.** The host MUST retain replaced and removed
-  occurrences, folded notices with their `data`, both rendered and fallback content, and
-  discarded late render results in its durable/audit record (§13.2), linked by `eventId`.
-  Only the model-visible context is rewritten. Operator surfaces SHOULD show displaced
-  occurrences as displaced.
+- **Humans and logs are not models.** Only the model-visible context is rewritten. A host
+  that keeps an audit record (§13.2) SHOULD record replaced and removed occurrences, folded
+  notices, and discarded late render results in it, linked by `eventId`, under the same
+  retention policy as its other audit entries. Operator surfaces SHOULD show displaced
+  occurrences as displaced. Nothing in this RFC requires retaining content its author
+  withdrew before anyone read it beyond what the host's audit policy already keeps.
- **Late binding.** With deferred mode the content is chosen at consumption time. The
  server learns that an inference is imminent and nothing else — no content, no
  conversation identity; `push/render` carries only its own subject. `inference/lifecycle`
@@ -735,8 +827,14 @@ It is deliberately *not* a leaf under `pushEvents` or `channels`: §5.1 defines
  could equally have pushed the bland event with the urgent tag; tags are claims, never
  authority (§16.6). The audit record shows both.
- **Resource use.** Keys are bounded; hosts MAY bound tracked subjects per server and evict
-  (an evicted occupant is treated as consumed; an evicted batch is materialized as its
-  fallback; an evicted history bit becomes `unknown`). Render is bounded by timeout.
+  (an evicted occupant is delivered as an ordinary occurrence and thereafter treated as
+  consumed; an evicted batch is materialized as its fallback; an evicted history bit becomes
+  `unknown`). Render is bounded by timeout. **Capacity is never a reason to refuse.** A host
+  at its subject limit admits the occurrence and degrades to appending (`"appended"`, or
+  `"noted"` for a retraction) exactly as a host without coalescing would; it MUST NOT
+  return an error for an occurrence it would have accepted without `coalesce`. In a busy
+  channel the number of unread subjects is the size of the backlog, and a refusal there
+  would drop the message that mattered.

## 12. Alternatives considered

@@ -772,6 +870,7 @@ interface Coalesce {
  channelId?: string;     // push/event only: select channel scope (§3.2). Requires channelScopedPush.
  deferred?: boolean;     // push/event only. Default false.
  retract?: boolean;      // Default false. Exclusive with deferred.
+  initial?: boolean;      // Default false. "No earlier occurrence of this subject was sent" (§3.3). Exclusive with retract.
  data?: unknown;         // deferred only; ≤ 4 KiB serialized; server-private
}
interface Notice        { eventId: string; timestamp: string; data?: unknown }
@@ -793,8 +892,8 @@ interface PushRenderResult { content: ContentBlock[]; timestamp?: string }
// new method                      push/render  (Host → Server, Request)

-Malformed coalesce — missing or oversized key; retract with deferred; data
-without deferred or over 4 KiB; deferred or channelId on a channels/incoming
+Malformed coalesce — missing or oversized key; retract with deferred or with
+initial; data without deferred or over 4 KiB; deferred or channelId on a channels/incoming
message; a channels/incoming message with coalesce but no non-empty eventId;
channelId or deferred sent to a host that does not advertise the corresponding leaf —
is a request error, never a silent append:
@@ -821,7 +920,8 @@ Plain:
"appended".
6. Repeated edits. Create, edit₁, edit₂, edit₃, no inference → one occurrence in the
next request, edit₃'s content, "replaced" ×3.
-7. Restart. E1(K); host restarts without a durable index; E2(K) → "appended".
+7. Restart. E1(K); host restarts without a durable index; E2(K) → "appended", and

  • the next request contains E1's content as well (vector 40).

Identity and scope:
8. Occurrence dedup. channels/incoming with (messageId M, eventId a) then
@@ -916,41 +1016,56 @@ Retraction:
31. Restart before deletion. Create; inference or not; host restarts without durable
history; retract with notice → "noted" (history unknown).
32. Unknown subject. Retract with notice for a key never seen → "noted".
-33. Chat notice required. Retract on a channel-scoped subject with empty content →

  • -32602.
    +33. Pure withdrawal in channel scope. Retract on a channel-scoped subject with empty
  • content and history some → "consumed"; nothing appended. (Revision 6 rejected this
  • with -32602; a presence subject has nothing to announce.)
  1. History persists past slot. Create; inference; retract ("noted"); new create
    under the same K; retract → "noted" (bit is some, not reset by the empty slot).
    +34a. Initial after eviction. Host evicts or restarts; create with initial: true
  • under a key it no longer tracks; edit; retract with notice → "retracted"; the next
  • request contains nothing from K. The same sequence without initial → "noted".
    +34b. Initial cannot lower history. Create; inference; a second occurrence with
  • initial: true under the same K; retract with notice → "noted".
    +34c. Capacity degrades, never refuses. With the host at its subject limit, a new
  • channel-scoped create → accepted: true, "appended"; the content reaches the next
  • request. A retract at the limit → "noted", notice delivered.

Both:
35. Idempotent retry. Same eventId twice → one effect; equal results.
-36. No second wake / no starvation. Replacement before a pending wake fires → exactly

  • one inference; replacements arriving faster than the debounce, forever → inference
  • still occurs within the host's bound.
    -37. Audit. After vectors 2, 16, 23 and 29 the durable record contains E1; N1–N3 with
  • rendered and fallback content; the discarded late result; and the removed edit — each
  • marked displaced/folded/discarded.
    +36. No starvation. Replacements arriving faster than the host's quiet period, forever →
  • the subject's current content still reaches a request within the host's stated bound.
  • (Whether the replacement causes one wake or none is host policy and not tested here.)
    +37. Audit (illustrative; host policy). After vectors 2, 16, 23 and 29 a host that keeps
  • an audit record shows E1; N1–N3 with rendered and fallback content; the discarded late
  • result; and the removed edit — each marked displaced/folded/discarded.
  1. Malformed, per lane. 257-byte key on push/event → -32602. In a
    channels/incoming batch of three where the second has deferred: true → results
    [accepted, {accepted:false, reason:"coalesce_invalid"}, accepted].

-Recovery and treatment:
+Recovery:
39. Reconnect retry. E1 accepted; receipt lost; transport reconnects and policy completes;

  • retry E1 → original receipt, no second occurrence or wake.
    -40. Recovered unread subject. Persist pending E1; restart; current policy restored;
  • E2 for the same binding/scope/key replaces it, or a retract removes it. Retain known
  • consumed history when durable; missing history remains unknown.
    -41. Offline at host restart. Restore pending fallback while the producer is offline;
  • reconnect and re-establish authority → reconsider its wake under current host policy.
  • retry E1 within the retry window → original receipt; the next request contains E1's
  • content exactly once.
    +40. Nothing accepted is lost. E1 accepted; transport drops, or the host restarts, before
  • any inference; the producer returns → the next request contains E1's content (as the
  • pending occupant or as an ordinary occurrence — either conforms). E2 for the same
  • subject after recovery → "replaced" or "appended", and the request contains E2's
  • content and at most one of E1/E2.
    +41. Ordinary traffic unaffected by reconnect. A registered channel; transport drops and
  • reconnects; a channels/incoming message without coalesce → accepted exactly as it
  • would have been under SPEC §14 before this RFC. This RFC adds no registration step.
  1. Binding reassignment. Reuse a configured label for another endpoint/principal;
    its events cannot replace, retract, or deduplicate the old binding's occurrences.
    -43. Current wake treatment. A pending immediate/debounced wake is replaced by an event
  • that matches skip → no obsolete unstarted wake remains.
    -44. Independent debounce. A coalesced subject and unrelated ordinary event match the
  • same debounce policy → the subject keeps its bounded deadline; the unrelated event
  • retains its own quiet period.
    -45. Stable reply target across lanes. Create M via channels/incoming; edit via a
    +43. Stable reply target across lanes. Create M via channels/incoming; edit via a
    channel-scoped push without a new platform message identity → M remains the reply
    target, regardless of whether the create was already consumed.
    +44. Per-context replacement (only for hosts that track contexts separately). E1
  • delivered to contexts A and B; A consumes it; E2 → A holds E1 then E2, B holds E2 only;
  • outcome is "appended".

+Illustrative, not conformance: whether a replacement matching skip leaves a wake
+queued, and whether coalesced and ordinary events share a debounce timer, are host policy
+(§4.2) and cannot be observed on the wire; revision 6's vectors 43–44 are withdrawn.

15. Implementation notes (non-normative)

@@ -978,8 +1093,8 @@ Recovery and treatment:
unconsumed until the hook flushes them; plain replacement is a keyed overwrite of the
held queue, rendering happens in the flush, and anything already emitted as a
<channel> block is consumed.
-- discord-mcpl / portal-mcpl. Add coalesce.key = "message:<id>" and an eventId on

  • creates (both paths), coalesce.channelId + fresh occurrence ids on edits (fixing the
    +- discord-mcpl / portal-mcpl. Add coalesce: { key: "message:<id>", initial: true }
  • and an eventId on creates (both paths), coalesce.channelId + fresh occurrence ids on edits (fixing the
    discord_edit_<id> collision), and retract + notice on deletes. Portal's
    [message deleted] push becomes the retraction's content unchanged.
  • First deferred servers. A Google Docs server (edits keyed per document, comments per
    @@ -991,9 +1106,18 @@ Recovery and treatment:
    across lanes, and the mixed-delivery cases (§9.2) are the point; splitting would leave
    chat servers with half a mechanism. Hosts that cannot replace inside a batched user turn
    advertise channelsIncoming: false.
    -2. Tail positioning is SHOULD. A replacement's content is true as of its own
  • timestamp; placing it where the stale occurrence sat would misorder it against anything
  • that arrived between.
    +2. Positioning is the host's, above the consumed boundary. Revisions 2–6 recommended
  • the tail because a gauge's content is true as of its own timestamp. For a chat message
  • the same rule moves an edited message after its replies. Both placements keep the
  • consumed prefix intact, which is the only property a server or a model can rely on, so
  • the RFC states that constraint and no preference (§4.1).
    +2a. The RFC specifies outcomes, not host mechanisms. Revision 6 required pending work
  • to survive interruptions as replaceable state, receipts to be kept without bound, wakes
  • to be cancelled and debounce timers separated, and audit records to be retained. None
  • of those is observable by a server or a model except through the outcomes they were
  • meant to secure — nothing accepted is lost, a retry within the window is not a second
  • occurrence, a changing subject is still heard. Revision 7 keeps the outcomes and returns
  • the mechanisms to the host (principle 4: on any uncertainty, behave as today).
  1. Consumption is permanent. No un-consume after a traceless request; with deferred
    mode the materialized occurrence is history either way, and refusal-rewind's marker is
    an ordinary event.

</details>

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>
@antra-tess
antra-tess merged commit 3a8647c into main Oct 1, 2026
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.

1 participant