diff --git a/changelog.d/269-header-invisible-characters.fixed.md b/changelog.d/269-header-invisible-characters.fixed.md new file mode 100644 index 000000000..aa7ba217f --- /dev/null +++ b/changelog.d/269-header-invisible-characters.fixed.md @@ -0,0 +1 @@ +- A source header no longer lets invisible or space-lookalike characters pass raw (#269). A value is quoted when it holds a format or default-ignorable character (zero-width characters, bidi overrides and isolates, fillers such as U+3164, the combining grapheme joiner, variation selectors), a space other than U+0020, or a `/` with whitespace on both sides; inside the quotes those characters are escaped as `\uXXXX`, so a quoted bidi override can't reorder what follows it. A label that starts with such a character is quoted, so a header word (`thread`, `reply to`, `unscoped`) behind it can't read as a field. Emoji sequences are exempt, joiners and selectors included, since a label is also a name a resident types back: `❤️ cats` stays as it is. The exemption is a pictographic character (symbols such as © and ® included) with an optional skin-tone modifier or presentation selector, joined to more by ZWJ; it doesn't check that the sequence is one Unicode recommends. Body text's header-shaped openings are marked through invisible and control characters too (`[​source:` with a zero-width space), wherever they stand inside the bracket. discord-mcpl's `source-header.ts` (#63) renders the same header by the same rule. diff --git a/changelog.d/inbound-source-envelope.added.md b/changelog.d/inbound-source-envelope.added.md new file mode 100644 index 000000000..ff472c601 --- /dev/null +++ b/changelog.d/inbound-source-envelope.added.md @@ -0,0 +1,22 @@ +- **Inbound source envelope.** Every inbound item the host accepts now carries + `metadata.inboundSource`, stamped once where it was accepted: the + conversation it belongs to (`channel`: MCPL server, endpoint binding, + canonical channel id, thread, message, reply edge and the channel's label at + receipt; `unscoped` for a push that names no channel; `surface` for console + or API input from a module), the host acceptance time and the adapter's own + timestamp. It covers `channels/incoming` and `push/event` on both the + ordinary and the RFC-006 coalesced paths; a coalesced occurrence freezes its + envelope at acceptance, so deferral, fan-out and replay deliver it unchanged + and a later rename never rewrites it. Adapter-supplied metadata cannot set + the field, and one a module's messages carry is dropped (an adapter's own + `origin.source` string is untouched). `readInboundSource`, + `conversationKey` (a JSON-encoded key, so no server-chosen part can run + into another) and `INBOUND_SOURCE_KEY` are exported. +- **Inbound source envelope: admission facts.** An ordinary `channels/incoming` + message or `push/event` now has its envelope frozen at admission, before it + is queued or acknowledged, so a rename or rebind while it waits cannot + change it. Every channel and unscoped envelope names the admission `lane` + (`channels/incoming` or `push/event`), and one that came through RFC-006 + coalesced admission has `coalesced: true` (others have no `coalesced` + field): facts a consumer needs to know what an `eventId` is worth. + `readInboundSource` validates every field it returns. diff --git a/changelog.d/ingestion-body-digests.added.md b/changelog.d/ingestion-body-digests.added.md new file mode 100644 index 000000000..0242f0bf8 --- /dev/null +++ b/changelog.d/ingestion-body-digests.added.md @@ -0,0 +1,32 @@ +- **Delivered-body digests on every stored MCPL inbound item.** Both MCPL + lanes stamp two fields, outside the frozen source envelope (console and API + input, and the host's own notices, carry neither): + - `metadata.sourceBodyDigest` is the version identity of the body as + delivered, taken at ingestion, after MCPL conversion and before any host + decoration or storage sharding. It is the SHA-256 (hex) of `[blocks]` + as canonical JSON (keys sorted at every level, `undefined` object entries + dropped, and an `undefined` array element written as `null` as JSON + writes it, though the store refuses a body holding one), with the blocks + as the store keeps them: inline media as + the store hands it back (base64 re-encoded from its bytes, an image's + media type taken from its bytes' signature, only `type` and `source` + kept), strings well-formed (a lone surrogate as U+FFFD) and an entry + whose key isn't well-formed dropped, as the store's append drops it. So + the body hashes alike as delivered, as handed to storage and as read + back, and an undecorated, unsharded stored copy hashes to it. + - `metadata.storedBodyDigest` uses the same function over exactly the + blocks handed to storage, decorations included, taken where each path + stores. + + Both are always the host's own: values an adapter supplies are replaced. + A stored copy, read back with its blobs resolved, that no longer hashes to + its `storedBodyDigest` was changed after it arrived (`editMessage` keeps + metadata). That check is of an unsharded copy: a body the store shards + (context-manager's adaptive ingress chunking) carries the whole body's two + digests on every shard (`bodyGroupId` and `shardIndex` mark them), and no + shard hashes to them. A `blob_ref` block is digested as given, while the + store resolves it on read, so a copy holding one doesn't match; the MCPL + lanes never store one. `sourceBodyDigest(blocks)` is exported from the + package root as the one implementation consumers share; it carries the + framing and the store's normalization, so check a copy with it rather than + hashing your own serialization. diff --git a/changelog.d/mcpl-request-outcome.added.md b/changelog.d/mcpl-request-outcome.added.md new file mode 100644 index 000000000..241ec226e --- /dev/null +++ b/changelog.d/mcpl-request-outcome.added.md @@ -0,0 +1,10 @@ +- **`McplRequestError`: why an MCPL request produced no result.** A request + to an MCPL server that fails now rejects with `McplRequestError` (exported), + whose `outcome` says which of three things happened: `not-sent` (refused + before anything was written: the connection was already closed), + `error-response` (the server answered with a JSON-RPC error; `code` and the + error's `data` are kept), or `no-response` (the request was handed to the + transport and no answer came back: a timeout, or the connection closed + while awaiting; it may or may not have reached the server). Only + `not-sent` proves the server never saw the request. Messages are unchanged, + and it is still an `Error`, so existing handling keeps working. diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md new file mode 100644 index 000000000..f55b6d5a2 --- /dev/null +++ b/changelog.d/prose-drafts.added.md @@ -0,0 +1,78 @@ +- **Held prose drafts: suppressed speech is kept, not destroyed.** Plain + speech that is not sent is now held as a draft with its exact words, + instead of surviving only as a count: speech suppressed by an + explicit send in the same round (locus and hybrid modes, mid-turn, trailing + and text-only, native and XML tool modes), hybrid prose left without a + destination after a failed envelope, and explicit/hybrid bounces (which + replace the in-memory latest-wins clipboard; `{{unsent}}` keeps meaning the + latest bounce). A draft held beside a send keeps its run exactly as + written, indentation and surrounding newlines included; routing still + publishes trimmed segments. Deliberate privacy is never drafted: + `skip_reply` (the tool, or a hybrid `>>>skip_reply` envelope, which keeps + the rest private until a destination is named), a same-round private + `think` in a native round (where that policy applies: XML tool mode routes + such prose as ordinary speech, so a send holds it as a draft), silent turns + and `proseRouting: disabled` narration stay private and are counted in the + receipt. +- In hybrid mode a send withholds publication, not what an envelope does: a + held `>>>` envelope moves the routing state as live routing would (a + destination it names becomes the sticky target), `>>>skip_reply {{unsent}}` + still sets the latest bounce aside, and the draft holds the message the + envelope would have published, with `{{unsent}}` expanded under the + re-bounce rule (a bare `{{unsent}}` leaves the bounce as the one draft). +- Drafts live in typed Chronicle records (`RecordJournal`) that do not follow + branch switches: undo, rollback and checkout never change them, and they + survive restarts. Nothing is ever sent from them on its own. Each + resident's 100 newest drafts are kept, and an older one for 7 days after + anything last happened to it (held, named, attempted, answered, dismissed); + the journal checkpoints what it keeps every 500 entries, so opening it + replays only what follows. A request naming a draft that was let go is + refused, and nothing is sent. +- A private notice names held drafts at the next tool boundary when the live + stream presents mid-turn messages (a draft holding words copied from one + that may already have been posted is named with that risk and the + `confirmDuplicate` its resend needs), and the turn-end `[delivered]` receipt + names every draft held in the turn by its state at turn end: still held + ("held as drafts d-… (not sent …)"), delivered by the resident's resend + (listed where it landed), unconfirmed, or dismissed. A dismissed draft + whose own attempt may already have been posted is named with that risk, + and counts like an unconfirmed one: a receipt that names one, with nothing + confirmed delivered, reads "nothing confirmed", not "nothing". A draft + counts as named while a message naming it is on the current branch, so one + no notice reached (a crash, an aborted turn), or whose notices a rollback + or branch move took away, is named at the next turn's start: every such + draft by id, the newest eight with a preview. +- A draft is never published except by its resident's explicit resend (or + `{{unsent}}`), and only that resident can list or act on it. Its notices go + into the resident's own history, which residents sharing one message slot + already share, as they share the assistant turn the words came from. +- New `drafts` tool (whenever MCPL channels are configured): `list`, `read`, + `resend` and `dismiss`, scoped to the calling resident. Every such + resident's tool list gains it, so each one's first request after upgrading + misses the prompt cache once. `resend` publishes + drafts verbatim, in order, to one destination through the normal publish + path, journaling each attempt durably before the request leaves; it counts + as an explicit send. A delivered draft returns its historical receipt + instead of sending again; a draft with any attempt whose outcome is + unknown (and none confirmed) needs `confirmDuplicate: true`, and a resend + owns its drafts until it finishes, re-checking each before it is sent, and + a draft it doesn't attempt is told why the batch stopped. Dismissing a + draft (with the tool or `>>skip_reply {{unsent}}`) is refused while that + draft's attempt is out, since a dismissal can't call back words already on + their way; the resident is told to wait for that result. A draft only + queued in a resend can still be dismissed, and is then not sent. A + dismissal of a draft that may already have been posted names that risk. + Unused fields may be passed as null. +- `{{unsent}}` whose draft is being sent, queued in a resend, or unconfirmed + sends nothing, and the envelope's own words are held as a bounce draft + with `{{unsent}}` written out and the risk carried, so nothing the + resident wrote has to be typed again. +- Prose segments also break at XML tool mode's refused attempts and their + notices (`tool_attempt`, `tool_notice`): the words before an all-refused + call and after its notice are two messages, routed or held separately. +- `ChannelRegistry.deliverSpeech` (routeSpeech with its `PublishOutcome`) and + `ChannelRegistry.resolveDestination` are new. `routeSpeech` now delegates + to `deliverSpeech` and returns null unless delivery was confirmed, so a + missing receipt and a channel id two servers share now return null (see + the publish-outcomes entry). +- The bounce notice now goes to the bouncing resident rather than the primary. diff --git a/changelog.d/publish-outcomes.fixed.md b/changelog.d/publish-outcomes.fixed.md new file mode 100644 index 000000000..6dd88191e --- /dev/null +++ b/changelog.d/publish-outcomes.fixed.md @@ -0,0 +1,23 @@ +- **Speech delivery reports what a publish actually established.** Plain + speech now goes through one publish executor (`ChannelRegistry.publish`, + returning a `PublishOutcome`: `delivered`, `failed` or `unknown`, with the + destination the registry resolved). Only a connector's `delivered: true` + confirms a post; a missing or malformed receipt is no longer counted as + delivered (#163's rule). `failed` means nothing was posted: refused before + dispatch, or `delivered: false` naming no message. An error response, a + timeout or a lost connection after dispatch is `unknown`, keeping the + connector's own words (a multi-part send may already be partly posted), and + so is a contradictory receipt (`delivered: false` beside a message id, + valid or not). + The resident's `[discord-send-failed]` marker now says which: "could not be + delivered … Nothing was posted" for a failure, and "was not confirmed … may + or may not have been posted; check the channel before sending it again" + for an uncertain outcome. The marker's metadata and the + `mcpl:speech-route-failed` trace carry `outcome`. A channel id registered + by more than one MCPL server is refused rather than routed through + whichever server registered it first, and a supplied but empty or + non-string server selector is refused rather than read as omitted. A send + refused for its server or grant opens no closed channel. A + `channels/publish` request always has a deadline: with `requestTimeoutMs` + 0 it keeps the 60 s default, since its outcome is told to the resident and + recorded for its draft; its expiry reads as unknown. diff --git a/changelog.d/source-headers.added.md b/changelog.d/source-headers.added.md new file mode 100644 index 000000000..19face025 --- /dev/null +++ b/changelog.d/source-headers.added.md @@ -0,0 +1,70 @@ +- **Visible source headers on channel traffic.** Every channel-bearing item + from either MCPL lane begins with a header naming the conversation it came + from: `[source: / ·