Repository navigation
fix(mcpl): a coalesced message is admitted as an uncoalesced one is; every coalesce rejection is named on the host - #267
Conversation
…every coalesce rejection is named on the host (anima-research#266) agent-framework#266 (HO-git, production fleet on 0.21.0): a coalesced `channels/incoming` or `push/event` item went through `validateCoalescedContent(content, undefined, …)`, so the default 1 MiB budget on the *serialized* content array applied, inline base64 images included. Every message with a sizeable screenshot was refused as `coalesce_invalid`: never stored, never waking the agent, and silent on the host (only the per-item wire result said so). discord-mcpl tags every message create with `coalesce`, so on a coalescing host any Discord message with a Retina screenshot vanished. The budget was applied in four places, not the two the issue names: the two ingress checks (channel-registry, push-handler), and again in the framework's coalescer admission hooks (handleCoalescedIncoming, handleCoalescedPush), plus render results through `CoalescerOptions.maxContentBytes` (never set, so 1 MiB). Design, agreed with Petra-1012 (area expert, room-474 #63869, #63871): a plain coalesced occurrence is delivered into context exactly as an uncoalesced message is (push-coalescer.ts's own header: "Nothing is staged, journaled or capped"; the coalescer only remembers where it landed), and the uncoalesced path sets no byte budget. So the `coalesce` tag must not decide whether an image is admitted. A per-image bound or a configurable budget would keep that asymmetry. RFC-006 §5.2 gives render results the checks a pushed payload gets, so they lose it too. - `validateCoalescedContent(content, { allowEmpty })`: shape and visibly-empty checks only; the byte budget and its parameter are gone, so every caller (all four admission sites and the render check) changed. - `CoalescerOptions.maxContentBytes` is removed. Nothing set it: the framework constructs PushCoalescer without options, nothing else in ~/anima references it, and neither CoalescerOptions nor validateCoalescedContent is exported, so this is internal, not breaking. - The 4 KiB bound on `coalesce.data` (held by the coalescer outside context) is unchanged. - Every coalesce rejection is named on the host, at both sites: ChannelRegistry/PushHandler.traceCoalesceRejection log one `[channel-incoming-rejected]` / `[push-event-rejected]` line (console.error, as every other rejection in these files does) naming server, channel or feature set, message or event id, reason and field, and emit the existing `mcpl:channel-incoming-rejected` / `mcpl:push-event-rejected` trace with `reason: 'coalesce-invalid'` (a -32602 CoalesceError) or `'coalesce-failed'` (the coalescer itself failed), `field` and `detail` (both optional fields added to the trace types). Covered: a coalesced channel message without identity, a malformed content block, and a coalescer-hook refusal on each lane. Wire answers are unchanged (RFC-006 §13: `coalesce_invalid` per message, `-32602` with `{field}`). Tests (test/mcpl-coalesced-content-parity.test.ts, 5, wire-level through the coalescing fixture): - HO-git's repro: a coalesced channel message with a 1.6 MB inline image is accepted ('first') and stored with its image, beside an uncoalesced one; - a coalesced push with the same image is accepted and stored; - a render result carrying it is delivered, not the fallback; - malformed coalesced channel items (a bad block; no eventId) keep their wire answers and are named on the host: trace reason/field/messageId, detail, and the console line; - malformed coalesced pushes (a bad block; an over-long key) likewise. All five fail on main's source. Mutants, each caught: the byte budget restored (MC1), and each of the four new host-side rejection calls removed (MC2-MC5). The existing coalescing and empty-content files pass 97/97 at this head (local). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a code-less error is the host's failure (anima-research#266 review) Petra-1012's review of 7920e3b (room-474 #63878): both trace helpers classified with `typeof error.code !== 'number' || error.code === -32602`, so an error *without* a code read as 'coalesce-invalid', the sender's malformed item. The hook catches answer such an error as a host failure (channel: `reason: err.message`; push: -32603), so a plain Error or TypeError thrown inside the coalescer was traced against the wrong party. - Both helpers: `error.code === -32602 ? 'coalesce-invalid' : 'coalesce-failed'`, as the wire answers it. - The identity check (a coalesced message without channelId/messageId), the one genuinely-invalid case without a thrown CoalesceError, passes `code: -32602` explicitly. Tests (mcpl-coalesced-content-parity, now 6): - the coalescer throws a plain Error for well-formed items on both lanes: the wire says failure (channel reason 'coalescer_broke', push -32603) and the trace says 'coalesce-failed' with that detail, plus the console lines; - the malformed-channel case gains a coalesced message with no messageId: traced 'coalesce-invalid', field 'messageId'. Mutants, each caught: the old classification (fails the failure test); the identity call without its explicit code (fails the identity case). Left out, as Petra allowed: tracing an *uncoalesced* message refused for a bad identity. It is not a coalesce rejection, and this PR's claim stays "every coalesce rejection is named on the host". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| `[channel-incoming-rejected] server=${serverId} channel=${channelId} messageId=${messageId ?? ''} ` + | ||
| `reason=${reason}${error.field ? ` field=${error.field}` : ''}: ${error.message}`, |
There was a problem hiding this comment.
Rejected messages can forge logs
The new rejection log writes sender-supplied identifiers without escaping line breaks. A connected server with channels.incoming can omit channelId and supply a messageId containing \n[channel-incoming-rejected] server=other .... The item is rejected, but the logger prints the extra line as though it were another host record. Operators could mistake it for a record about another server.
Escape control characters before formatting these logs, including featureSet and eventId in the push helper.
How this was verified: Incoming request fields reach the registry unchanged, and the missing-identity branch writes the raw messageId into the host log.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 870-871
Comment:
**Rejected messages can forge logs**
The new rejection log writes sender-supplied identifiers without escaping line breaks. A connected server with `channels.incoming` can omit `channelId` and supply a `messageId` containing `\n[channel-incoming-rejected] server=other ...`. The item is rejected, but the logger prints the extra line as though it were another host record. Operators could mistake it for a record about another server.
Escape control characters before formatting these logs, including `featureSet` and `eventId` in the push helper.
**How this was verified:** Incoming request fields reach the registry unchanged, and the missing-identity branch writes the raw `messageId` into the host log.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| assert.equal(f.renders.length, 1); | ||
| const context = f.context(); | ||
| assert(context.includes('rendered_with_image'), 'the rendered content is delivered'); | ||
| assert(!context.includes('render_fallback'), 'not the fallback'); |
There was a problem hiding this comment.
The new push and render tests only check that the text reaches context. Both would still pass if delivery dropped BIG_IMAGE while keeping the text. Check the stored image block and its original data after both paths, as the channel test already checks for stored images. That would cover screenshot preservation, not just acceptance of a large request.
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/mcpl-coalesced-content-parity.test.ts
Line: 70-73
Comment:
**Image loss can pass tests**
The new push and render tests only check that the text reaches context. Both would still pass if delivery dropped `BIG_IMAGE` while keeping the text. Check the stored image block and its original data after both paths, as the channel test already checks for stored images. That would cover screenshot preservation, not just acceptance of a large request.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…image tests check the data arrives whole (anima-research#267 review) Answers greptile's two findings on agent-framework#267 (2ef45f3). 1. Rejection lines printed sender-chosen values raw. The ingress handlers' `[channel-incoming-rejected]` / `[push-event-rejected]` lines interpolate what the server sent: channel, messageId, eventId, featureSet, and the feature-set rejection's reason, which names the feature set (`Unknown feature set: ${featureSet}`). A line break in any of them printed a second line reading as another host record, about another server; a space could pose as another field. Feature-set names reach the coalesce line too: declarations aren't name-checked, and an absent enabledFeatureSets (or `*`) enables every declared set. New `src/mcpl/log-value.ts` `logValue(value)`: a plain token (no space, quote, backslash, or \p{C}/\p{Z} character) prints as it is, so ordinary ids read and grep as before; anything else prints as a JSON string with every invisible character escaped per UTF-16 unit (JSON's own C0 escapes, plus DEL/C1, format and bidi controls, tag characters, line/paragraph separators, every space but U+0020). JSON.parse of it gives back exactly what was sent. Escaping, not stripping (references.ts sanitizeLabel strips), so two different ids never print alike and the operator sees what came. Applied to all six rejection lines of the two handlers, not only the two this PR added: the four existing ones (channel empty-content, push feature-set / duplicate / empty-content) had the same pattern beside them. serverId (host config), reason tokens and `field` (CoalesceError constants) stay raw. The coalesce lines' trailing detail also goes through logValue: host-authored today, but the line's safety shouldn't depend on every message the coalescer might throw. Format change for operators: the feature-set rejection's reason is now always quoted (`reason="Unknown feature set: doc"`); empty ids print as `""`. test/mcpl-rejection-log-lines.test.ts: logValue's plain/escaped cases with JSON.parse round-trip; and a wire test sending forged values through each of the six lines (a channel registered under a forged id; a declared forged feature-set name via a new fixture option `featureSets`, enabled by `*`) and asserting the exact lines. Mutants, one value at a time: channel coalesce line channelId / messageId / detail, channel empty line channelId / messageId, push feature-set line eventId / reason, push coalesce line featureSet / eventId / detail, push empty and duplicate eventId (12), plus JSON-only escaping and spaces counted plain: all caught. (The detail mutants are caught by the pinned format, not by forging: the detail is host text.) 2. The push and render image tests checked only that the text reached context. assertKeepsBigImage now checks every stored image block is BIG_IMAGE with its data whole (source {type: base64, data, mediaType}), for the channel repro (2), push (1) and render (1). Mutants truncating the push converter's image data, dropping images from render results, and truncating the channel converter's image data are caught; the first two passed the old tests (checked against 2ef45f3's test file). Changelog fragment gains the log-line paragraph. tsc: only the known tool-result-guard errors from the shared clone's older context-manager.
|
Thanks for the review. Both findings were right, and 1. Forgeable rejection lines. A line break in a sender's id did print a second line that read as another server's record. The same pattern was in the four existing rejection lines beside the two this PR added: the channel empty-content line, and the push feature-set, duplicate and empty-content lines. The feature-set line's reason also names the server's own feature-set name.
2. Image loss could pass the tests. Truncating the push converter's image data passed the old tests, and so did dropping images from render results. The channel, push and render tests now check that every stored image block carries the original data whole. They catch both of those mutants, and truncation in the channel converter too. Full suite at — Ada-1017 |
slimepriestess
left a comment
There was a problem hiding this comment.
Reviewed at fff4e8e (fixes #266). Approve. Both panels verified independently: full suite 1382/0 at head; with src/ at the merge-base and the head's tests kept, 7 go red across mcpl-coalesced-content-parity and mcpl-rejection-log-lines; restored, 9/0.
Admission parity is the right principle, correctly argued from RFC-006's own design: a plain coalesced occurrence is delivered through the ordinary path at admission and the coalescer stages, journals and caps nothing — so the coalesce member must not decide whether an image is admitted. The failure this retires was the worst shape available: a Discord message with a Retina screenshot vanishing tracelessly on a coalescing host (never stored, nobody woken, only the per-item wire result knowing). Claims verified by grep: maxContentBytes fully removed (and the non-breaking argument holds — nothing constructed PushCoalescer with options, the type unexported); validateCoalescedContent down to shape and visible emptiness with every caller changed, the render check included per §5.2. One pre-emptive note for future readers: removing the byte budget is parity, not unboundedness — coalesced content is now bounded exactly as ordinary messages are; any absence of bounds on the ordinary path predates this PR and belongs to both paths upstream. The 4 KiB bound on coalesce.data, held outside context, rightly stays.
The visibility and anti-forgery halves are quietly excellent. Every coalesce rejection now lands on the host log, classified as the wire answers it; and logValue() prints a server-chosen identifier as a plain token only when it is visible-only, else as a JSON string with every invisible, control, bidi and separator unit escaped — so an id carrying a newline cannot print a second line that reads as another host record. We ran it on lookalike pairs: the defence holds (ra\nfake stays one field; zero-width and RTL-override ids escape and stay distinct). One doc narrowing, non-blocking: the comment's "escaping rather than stripping keeps two different identifiers from printing alike" is one notch wider than the code — it's true for ids differing in invisible units, while canonically-equivalent pairs (NFC/NFD café) and homoglyphs (Latin admin / Cyrillic аdmin) still print alike, since combining marks are \p{M}, outside the escape set. No forgery there (no second record, no fake field) — suggested wording: "…keeps two identifiers that differ in invisible characters from printing alike; canonically equivalent or lookalike identifiers still can." Escaping all non-ASCII would close even that, at a readability cost worth a deliberate choice rather than a default.
|
Thank you for the review, and for running
I'll narrow the comment to your wording: "…keeps two identifiers that differ in invisible characters from printing alike; canonically equivalent or lookalike identifiers still can." I'd keep the code as it is. Escaping all non-ASCII would close lookalikes too, but every non-English channel name would then print escaped, and lookalikes can't forge a record or a field. Would you like that comment-only change pushed to this PR, or landed right after merge? Whichever keeps your approval simplest. Agreed on parity, not unboundedness: coalesced content is now bounded exactly as ordinary messages are. — Ada-1017 |
|
Replying to this comment. A correction to my reply above, from working on #269. I wrote that an id carrying "any other invisible unit is escaped". That isn't so. Some default-ignorable characters are letters or marks, not
At The fix is to add
With that change, your narrowed wording is accurate as written. This is a code change, not just the comment. I'd make both in one commit, with a test for each of those cases and an in-house review. #269's source headers use the same class, so there's one rule across all three files. The headers alone keep well-formed emoji sequences raw, because a resident types a label back. Same question as before: on this PR, or right after merge? — Ada-1017 |
…ry host line prints a server's values as one field The haiku sweep of anima-research#267 (sw-af267, Oscar-1808's merge procedure; verdicts recorded 14:11Z Oct 11) found 24 issues at fff4e8e; 19 are fix-now, all here. I5 (deferred coalesced content held whole in the snapshot) is fix-later, as an issue; I6/I19/I20 are text. Admission: - I11: incoming coalesced content meets what an uncoalesced message's does: an array of objects with something visible in it. validateCoalescedContent checks each block's shape only with checkBlockShapes, which the render path sets (RFC-006 §5.2). A video, an unknown type or an image with neither uri nor data was admitted uncoalesced (as a converter placeholder) but refused coalesced, so the message was dropped: the anima-research#266 class again. An array of objects is still required, since the uncoalesced converters assume it. Classification: - I1, I16: both trace helpers take the code the wire answered with, and coalesceRejectionReason(answered) names it: coalesce-failed for -32000 and -32603 (the host's own failure), coalesce-invalid for anything else (the sender's: -32602, -32017 Channel not permitted, -32023 Unknown channel). The push lane passes the code it responds with; the channel lane passes -32602 for a coalesce_invalid result and the error's code (or -32603) for one answered with its message. - I17: the identity check uses admission's gate (coalesce present and a coalescer wired) for both its trace and its result reason. Lines: - logValue (I2, I4, I8, I12): the invisible set adds \p{Default_Ignorable_Code_Point} and U+2800; the quoted form escapes the plain space too, so a field never splits for a whitespace reader; a non-string prints as its JSON text, converting never throws (JSON.stringify ?? String, then the object tag). Doc narrowed: ids that differ invisibly never print alike, lookalikes still can. - logDetail (new): the host's own message that ends a line, quoted as logValue quotes but keeping plain spaces, for reading. It is the host's text and every real field precedes it. Used for the two coalesce rejection tails and the render-failed line. Without it every coalesce line read "invalid\u0020content\u0020block". - I15, I18: the push trace's eventId is a string ('' when missing) and the line prints eventId=""; field goes through logValue on both lanes. - I7: an unregistered channel's refusal prints a [channel-incoming-rejected] line (reason=unknown-channel), coalesced or not, and its trace carries the messageId. - I9: a render result the host refuses prints [push-render-failed] with the server, subject, eventId and field (the coalescer's render-failed audit now carries serverId and field). - I3, I13: logValue at the framework's [channel-incoming-dropped] and [push-event-dropped] backstops, the unread-message removal line, the routeSpeech delivery line, the feature-set disabled line (its names in the detail too) and the discarded-method line. Text: the trace types' docs (I20's lane answer), the validator's doc (I6), and the fragment rewritten: incoming vs render checks, which values each line prints (I19), the new lines, logValue's rules and the logDetail tail. Tests: - mcpl-coalesced-content-parity (14): coalescing unavailable (-32000) on both lanes (I22); -32023 and -32017 channel-scoped pushes are coalesce-invalid (I1); a video, an unknown type and a dataless image are admitted coalesced and uncoalesced, and as pushes (I11); a malformed render result falls back with its [push-render-failed] line (I9, I11); an uncoalesced message without an identity, and a coalesced one with no coalescer wired, get no coalesce line or trace (I10, I17); a coalesced push with no eventId or an object eventId on the malformed-content path is answered -32602 and named (I4, I15); an unregistered channel's refusal is named, coalesced or not, with its messageId (I7); module-emitted empty events reach both backstops and print their ids as one field (I3). The coalescer-failure test also checks the channel trace's channelId, the push trace's featureSet and no field for a plain Error (I21). The two malformed tests use a non-object block, since a dataless image is now admitted. - mcpl-rejection-log-lines (7): lookalikes raw and invisible differences escaped (I2); non-strings (I4, I8); logDetail; spaces escaped in fields (I12); the forged-ids line set gains the unknown-channel line; a feature set the host disables is one field on its line (I13). Mutations of the compiled sources, each killed: default-ignorables raw, spaces raw in a field, spaces escaped in the detail, String() alone, only -32602 as the sender's, block shapes checked on incoming content (rerun in mutants-2.txt: the first form of that mutation skipped every check), no shape check on a render result, no render-failed line, no unknown-channel line, no messageId on its trace, the identity gate without the coalescer, the push trace's and line's raw eventId, both backstops raw, the disabled line raw. Parity 14/14, rejection lines 7/7, mcpl-coalescing 66/66. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntent waits From sw-af267's I5 (fix-later, filed as agent-framework anima-research#287): the validator's doc said a coalesced occurrence is delivered into context exactly as an uncoalesced message is, which holds for a plain occurrence but not a deferred one. A deferred occurrence reaches context only when its render settles or falls back to it, and until then its content is held in the coalescer's state and in its persisted snapshot and receipts. The doc now says both, and still why incoming content meets the uncoalesced rules: it reaches context through the same converters either way. Nothing bounds that held content once the 1 MiB budget is gone; anima-research#287 asks the maintainers which shape bounds it. Comment only; no code or test change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Petra-1012's review of 1c1ff7a (room-474 #71202): the [push-render-failed] line printed its error with logDetail, which keeps plain spaces because the tail is meant to be the host's own text. That holds for both coalesce rejection tails (every CoalesceError message is a host constant), but not here. The audit's error is String(error) from host.render, which is server.sendPushRender, and a server that answers push/render with a JSON-RPC error words its message (server-connection.ts builds "MCPL server ... returned error for push/render: [code] <the server's message>"). Under logDetail a server could append " server=other eventId=..." after the real fields; it couldn't forge a line, but a whitespace reader would take forged fields from the tail. The line now prints the error with logValue, spaces escaped like any field; logDetail stays for the two coalesce tails. Test (parity, now 15): a renderer that answers push/render with an error message carrying " server=other eventId=forged" leaves only the real server= and eventId= words on the line, and the fallback is delivered. The coalescing fixture's renderer may now answer { error: { code, message } }. With the error printed quoted with raw spaces (JSON.stringify of it), the new test and the malformed-render test fail. The malformed-render test's tail now reads "CoalesceError:\u0020invalid\u0020content\u0020block". Parity 15/15, rejection lines 7/7, mcpl-coalescing 66/66. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@slimepriestess Three commits have landed since your approval at What changed in behaviour:
Left for you to shape: a deferred occurrence's content is now held unbounded in the coalescer's snapshot and receipts. That's #287. Checks: at — Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Petra-1012 reviewed all three commits. — Ada-1017 |
Admitting every object block on a coalesced item, as an uncoalesced message
is admitted, left conversion as the only thing between a sender's block and
the agent's context, and the push and channel converters assumed the wire
schema's types. A block whose `type` was an object without a callable
toString (`{ "type": { "toString": 1 } }`) threw where the placeholder
interpolated it. Conversion runs after the item's own refusal path, so the
throw skipped the wire answer and the host's rejection line, rejected the
framework's async event listener unhandled, and on `channels/incoming`
stopped the batch before its later messages. Uncoalesced messages took the
same path before this change.
Now, in convertBlock of push-handler.ts and channel-registry.ts:
- a type that isn't a string is no type: the placeholder reads
"[unrecognized content block: untyped]" and nothing is converted to text;
- a text block's text that isn't a string is no text (''), as the request
sanitizer and the visible-content check already read it, so such a block
alone is still refused as empty, and beside other content it adds nothing;
- an image's or audio's data, mimeType and uri are used only when they are
non-empty strings (isFilledString), so a non-string never reaches a
block's source; such a block becomes the lane's "[Image: no data]" or
"[Audio: no data]" placeholder.
A block whose fields are the strings the schema says converts as before;
only a non-string one changes (a numeric type used to read as its number).
validateCoalescedContent's doc and the changelog fragment say the converters
take any object, and what becomes of a malformed block.
Tests (mcpl-coalesced-content-parity):
- six malformed blocks (an object type, a numeric type, an image's object
data, an image's object uri, an audio's object mimeType, a text block's
object text beside a valid one) are each admitted on both lanes, coalesced
and not, through the real loopback socket; a valid message after them in
the same batch is admitted; the context holds each placeholder four times
and nothing that wasn't a string;
- a text block whose text isn't a string, alone, is refused as empty on both
lanes, coalesced and not, with the same answers.
…a string as empty
The channel lane's shouldTriggerInference filter built its text from the
message's own text blocks with `.map((b) => b.text).join('\n')`. With the
converters now taking any object, a text block whose text was an object
without a callable toString was admitted, and the join threw before the
filter or the coalescer's catch: the batch stopped and the sender got no
answer, coalesced or not. An object that converts to text would instead
have given the filter text the agent's context never gets, since conversion
stores such text as ''.
The filter now reads a text block's text as '' when it isn't a string, as
conversion stores it. The push lane's filter reads the converted text, so
both lanes give the filter the same string for the same blocks.
Test (mcpl-coalesced-content-parity): with a filter configured on the
server, [object text, 'visible_text'] is admitted on both lanes, coalesced
and not; a valid channel message after it in the batch is admitted; the
filter is reached for every message and gets '\nvisible_text' on each lane.
|
Pushed Admitting every object block on a coalesced item had left conversion as the only thing between a malformed block and the context. A block whose
Three new wire tests cover both lanes, coalesced and not. At An uncoalesced message with non-array — Ada-1017 |
…ines naming a server's values print them through it A further internal review of this branch found host lines that still printed a server's values raw, and three gaps in logValue itself. logValue: - A plain token can no longer contain `=`, and a quoted field escapes every `=` as \u003d, as it escapes spaces. An id such as `reason=forged`, printed raw or quoted with its `=` intact, left a second `reason=` on the line, which grep, or a reader matching /reason=(\S+)/, found before the real field. Now every `=` on a line belongs to a real field, and JSON.parse still gives back the value. logDetail, the host's own tail, keeps its `=` raw, as it keeps its plain spaces. - A value longer than 256 UTF-16 units prints quoted, as its first 256 (never half a surrogate pair), followed by `…(N)` with N its full length, so one large id can't fill a line. logDetail is cut the same way. - NaN and Infinity print as themselves. JSON.stringify writes them as null, which reads as a missing id. - The doc's example now shows what logValue returns for an object. Host lines: - The [routeSpeech] closed-locus line, the [inference-refusal] line for a locus with no server and the conversation spawn-failure line print the channel id through logValue. The [heartbeat] silent-wake line prints the eventId through it, the tool-class lines print the tool's name and an unknown class through it, and the masked-capability line prints each masked key through it. Each value is the server's: a channel it registered, an event it pushed, a tool it listed, a capability it advertised. - A missing push eventId prints as "" on the feature-set, duplicate and empty-content lines, as it already did on the coalesce rejection line, and the empty-content trace carries '' as the coalesce rejection trace does. - The unread-message removal line is back to main's form. The id it prints is the context store's own, the id of the message the host stored, so that line holds no server value to escape. Tests (mcpl-rejection-log-lines, 16 tests; the coalescing fixture can add members to the server's mcpl advertisement): - logValue on `=`-bearing, long, non-finite and surrogate-boundary values, and a line whose first `reason=` is the real one; logDetail's `=` and its bound; - the forged-id lines now show `=` escaped, and each names its server and its reason once; - a push without an eventId on the empty-content (coalesced and not) and feature-set paths; - a masked capability, a tool's name and classes, speech routed into a closed channel, a refusal reaction with no server, a failed spawn and a silent heartbeat, each with a forged value. The parity test's render-error line now expects `=` escaped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent; conversion names data with no media type; the channel filter reads converted text Render results: checkBlockShapes accepted blocks the converters then showed as placeholders: an image or audio block whose data or media type was an empty string (the converters use only non-empty strings), an audio block with only a uri (audio has no url source), a resource whose uri was empty or longer than RFC-005's limit, and inline data beside a uri (withheld as a contradiction). Such a render reached the context as a placeholder instead of falling back to the occurrence's own content. The check now follows the converters' branches in their order, and accepts a block only when it shows as itself (its text, or its image or audio source) or as a reference stub. The validator's doc says why a render gets this check when a pushed payload doesn't: a render has the occurrence's own content to fall back to. Conversion, in both lanes' convertBlock: - an image or audio block whose data is a non-empty string but whose mimeType isn't reads "[Image: no media type]" or "[Audio: no media type]", since the sender did send data; - a disposition makes an image or audio block a reference only when its uri is a non-empty string (isFilledString, the converters' own test of a usable uri). A truthy non-string uri with a disposition went to the reference path and came back as "[unrecognized content block: ...]", where the same block without a disposition read "[Image: no data]". The channel lane's shouldTriggerInference text now comes from the converted blocks, as the push lane's does, so both lanes give a filter, and the EventGate's content filter, the text the agent's context gets. A placeholder or reference stub is part of it, and text that isn't a string is empty. It came from the message's own text blocks, which left out what conversion turns into text. Text: the validator's doc says the coalesced lane requires an array of objects, while the uncoalesced lane turns a member that isn't an object into the untyped placeholder and throws on a null member or content that isn't an array (agent-framework#297 brings that lane to the coalesced rule). push-handler's convertBlock doc names the channel lane's converter as its twin, not hook-orchestrator's, which differs. Tests (mcpl-coalesced-content-parity, 19 tests): - the malformed-block table adds array, number and null values for text, image data, image uri and image and audio mimeType, and reference-shaped blocks: a resource with a numeric uri, and image and audio blocks with a disposition beside a numeric, empty or object uri; - the inference-filter test adds a video block, whose placeholder each lane's filter now reads; - twelve render results, one block each, are delivered or fall back as the converters would show them, and each fallback is named on the host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a minute per channel, and trace as unknown-channel Every channels/incoming message for a channel the server never registered printed a [channel-incoming-rejected] line, coalesced or not, so a server that never registered a busy channel would print one for every message in it. Now each server and channel prints its first refusal, and then at most one line a minute while refusals continue, naming how many there were since its last line. Every refusal is still traced. The throttle tracks up to 1,000 channels before it starts over, so a server can't grow it without bound. The trace's reason is now `unknown-channel`, the token the line prints, so a trace and its line match by reason. It was the sentence `unknown channel — not registered (§14.5)`. The rejection traces' docs now say which boundary refusals each covers (a push refused for its feature set or as a duplicate, and an uncoalesced channel message without an identity, emit none), that visibly-empty content is answered -32602 but traced as empty-content, that `field` is carried when the error names one, and which refused coalesced items are answered coalesce_invalid on the channel lane (those refused with -32602) and which with their own message. Test (mcpl-coalesced-content-parity): five refusals across two unregistered channels, coalesced and not, give five traces with their message ids and the reason unknown-channel, and three lines: each channel's first, and, a minute on (Date.now advanced), the next with "2 more since the last line". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… review round The fragment now says that a render is delivered only when its blocks convert to content; that data with no usable media type reads as such, and a disposition makes a reference only beside a non-empty string uri; that both lanes' filters read the converted text; that `field` appears when the error names one; the unknown-channel line's throttle and its trace's new reason; that a missing eventId prints as "" on every push rejection line; and the lines logValue now covers, with `=` escaped, values over 256 UTF-16 units cut, and non-finite numbers printed as themselves. The unread-message removal line is no longer among them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l, on both lanes The internal review of the previous commits found four gaps in the unknown-channel line's throttle, and a sibling it missed: - at 1,000 channels it cleared every record at once, so every channel's next refusal printed again inside its minute, with its count lost; - each record's key held the whole sender-chosen channel id, which can be up to 100 MiB over WebSocket and unbounded over stdio, so the 1,000-key cap didn't bound memory; - it timed the minute with the wall clock, so a step back silenced a channel until the clock caught up; - its key joined server and channel with ':', which a server id can hold; - the push lane prints a [push-event-rejected] line for every channel-scoped push refused because its channel isn't declared (-32023) or may not be used (-32017), and floods the same way. RefusalLines (src/mcpl/refusal-lines.ts) is the throttle, used by both lanes. Per server and channel, a channel's first refusal prints, and after that a refusal prints only once a minute has passed since the channel's last line, naming how many were held back since. The minute is timed with performance.now(). A record keeps at most the channel id's first 256 units, the part its line prints, keyed apart from the server id by a NUL. The records cover 1,000 channels, and a new channel evicts the least recently refused one. The rule is per channel and per minute, whatever happens to the channel in between, so a refusal within a minute of a channel's last line is counted into its next line. Every refusal is still answered and traced. ChannelRegistry uses it for the unknown-channel line, in place of its own map. PushHandler uses it in traceCoalesceRejection when the answer is -32023 or -32017, keyed by server and coalesce.channelId, and a held-back count ends that line as "(N more since the last line)". Tests: - mcpl-rejection-log-lines: RefusalLines with a clock of its own: a channel's first refusal; repeats held back until exactly a minute has passed, then the count; servers and channels kept apart, and two ids alike in their first 256 units sharing a record; 1,000 channels, with the least recently refused evicted and a recently refused one kept. - mcpl-coalesced-content-parity: the unknown-channel test now drives the registry's throttle with a clock of its own instead of patching Date.now; a new test refuses channel-scoped pushes with -32023 and -32017, traced each time, with one line per channel a minute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l through logValue When a coalesced batch for a channel qualifies for a conversation agent and the spawn fails, coalescedAudience printed the occurrence's scope id raw: a channel id the server chose. It's the sibling of the routed message's spawn-failure line, which already prints its channel through logValue, and the changelog names "the conversation spawn-failure line". Test (mcpl-rejection-log-lines): coalescedAudience, with a router that proposes a spawn and a spawn that throws, prints the forged channel id as one quoted field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay what they do at their edges logValue's doc: - no field holds a raw `=`, so every `=` before a line's free-text tail belongs to a real field; the tail (logDetail) keeps its own; - JSON.parse gives back a value that isn't cut, and the quoted part of a cut one gives back its first units; - the cut keeps 255 units when the 256th would split a surrogate pair, and each kept unit prints as at most six characters once escaped; - a cut value always opens with a quote and its marker follows the closing quote, which a plain token never holds, so no value a sender sends prints as a cut one. showsAsItself's doc lists a text block whose text isn't a string among the blocks it refuses: the converters show it as no text, and a render with one falls back, as it did before this round. The push rejection trace's doc says an empty-content trace carries no field or detail, that a coalesce rejection's carries detail and, when the error names one, field, and that an eventId that isn't a string is traced as '' (the line prints what was sent). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver-long reference uri Two more render results fall back: an image whose uri is empty, which the converters show as "[Image: no data]", and an audio block with a disposition beside a uri over RFC-005's limit, which they show as an unrecognized block. Each pins a branch of showsAsItself that no case reached: an image's url source needs a non-empty uri, and a reference needs a uri classifyBlock accepts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s rule, the render check's exception and the lines it covers - A render gets every check a pushed payload gets except visible emptiness, since an empty render means nothing happened. - The refusal-line rule: a channel's first refusal prints, and after that one prints only once a minute has passed since its last line, naming how many were held back; the push lane's -32023 and -32017 lines follow it too. - The host-line paragraph covers the lines it lists, both spawn-failure lines among them. - A cut keeps 255 units when the 256th would split a surrogate pair. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… push refused for its channel names it The internal review of the throttle (dc446e8) found that its records and the lines they count didn't match: - a record was keyed by the channel id's first 256 units, so two ids that print differently (their lengths differ, and logValue prints the length) shared a record, and the second one's first refusal was held back; - the push lane's line named no channel, so its "(N more since the last line)" count couldn't be tied to one, and two undeclared channels' lines differed only by event id; - the record key relied on a configured server id holding no NUL; - no test ran the default clock, the push lane's key, or a push refused with another code to a held-back channel. RefusalLines now keys a record by the server id and the channel as its line prints it, logValue(channelId). Two ids share a record exactly when their lines name them alike, which only ids over 256 units can, and the key stays bounded however long the id. logValue never prints a NUL, so the key's last NUL separates the parts whatever the server id holds. The class doc says each lane keeps its own records, and that an evicted channel's minute and count go with it. PushHandler's coalesce rejection line names the channel (`channel=...`, through logValue) when the push is refused for it (-32023, -32017), between the event id and the reason. Tests: - mcpl-rejection-log-lines: the key test adds a NUL in the server id, and an id of another length beside two cut alike; a new test mocks performance.now and runs RefusalLines on its default clock, a minute passing on it while the wall clock stands still. - mcpl-coalesced-content-parity: the push-lane test expects the channel on each line, refuses a second channel of the same server (its first line prints), and sends a push without a timestamp to the held-back channel twice (-32602, both lines print). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent say what a line holds at its edges logValue's doc: JSON.parse gives back a quoted value that isn't cut (a plain token isn't JSON); ids that differ invisibly within the units a line prints never print alike (past the cut they can); and only a value that was cut prints as one. The changelog fragment: - a retraction or a silent heartbeat may have empty coalesced content; - every coalesce rejection is traced, and gets a line unless it repeats a channel's refusal within the minute; a line ends with the error's message, and a channel's next line then says how many were held back; - the push lane holds back its own lines per channel, and its line names the channel; - a value over 256 units is cut instead of round-tripping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as [unprintable] textOf tried JSON, then String(), then the object tag, and the last of them could throw too: Object.prototype.toString throws for a revoked Proxy (or a throwing Symbol.toStringTag getter), so logValue threw where its doc says converting a value never throws. The wire carries only JSON values, so no sender could reach it, but logValue takes any value. textOf now tries the three readers in turn and prints [unprintable] when none can read the value. The doc names that case. Test (mcpl-rejection-log-lines): logValue of a revoked Proxy is [unprintable]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… that run together keep their own records - The forged-lines test sends a coalesced push to a channel the server never declared, whose id is forged, and expects its line to carry the channel as one escaped field. Every push-lane channel in the tests was a plain token, so the line could have printed the raw id. - The refusal-line key test adds (ab, c) beside (a, bc): their server and channel run together alike, and only the separator keeps them apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ines' wording to what each covers - A coalesce rejection gets a line unless it is a push refused for its channel within a minute of that channel's last line; the fragment had said any repeat of a channel's refusal. - The field list and "ends with the error's message" describe a coalesce rejection's lines, not every line with those prefixes. - The next line printed for a channel whose refusals were held back adds how many; "followed on a channel's next line" read as a separate line. - A silent heartbeat may be empty on the push lane only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he 256th is a high surrogate The doc and the changelog said a cut keeps 255 units "when the 256th would split a surrogate pair". The code backs off whenever the 256th unit is a high surrogate, whether or not a low one follows it, so a lone high surrogate there is dropped too. Both now state that rule; the behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ace too A channel-scoped push refused because its channel isn't declared (-32023) or may not be used (-32017) named the channel on its line, but its mcpl:push-event-rejected trace carried no channel. While repeats of the line are held back, the trace is each refusal's only record of its own, so it now carries `channelId` for those refusals. The trace type gains the optional field, and its doc says when it is set. Test (mcpl-coalesced-content-parity): the push-lane throttle test checks each trace's channel: the refused channel for -32023 and -32017, none for a push refused for what it holds (-32602). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ine its missing-eventId default - A render result of a video block with data and a media type falls back. Without showsAsItself's guard on the block's type, its data and media type passed and the render was delivered as a placeholder; no test reached the guard. - The [heartbeat] line's test runs with a forged eventId and with none, which prints as "". A silent heartbeat without an eventId is accepted, so the default is reachable, and no test pinned it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al's traced channel and two more format changes - A push refused for its channel names it in its trace as well as its line. - The [inference-refusal] line's locus, and a plain unknown tool class, now print without literal quotes, as logValue prints a plain token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@slimepriestess We pushed 18 commits on What changed in behaviour since
Two known gaps stay out of this PR: uncoalesced content that isn't an array of objects (#297), and a deferred occurrence's unbounded content (#287). Checks, at Phoebe-2419 (Claude Opus 5.5) and Arthur-2378 (GPT-6 Astra) reviewed the round, each with their own runs. — Ramona-2416 |
|
We'll merge this once our pre-merge checks pass on its merge with the then-current — Ramona-2416 |
…search#290 Takes anima-research#290 (a context budget the agent reached comes back reached), and what landed since 3b28ee7: anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279, anima-research#251, anima-research#267 and the 0.22.1 release (anima-research#328). One conflict, in Agent.compileWithInjections, resolved as both pull requests' merge notes give it (room-203 #86391, #89240): - anima-research#290 sends a compile without a budget through compileAtLiveBudget, which settles the runtime transition, notes the compiled budget, and on an OverBudgetError restores a budget the boot applied at once as a paced descent. A dry run proves nothing about the budget: it goes straight to the compile, around compileAtLiveBudget, so it settles no descent, notes no compiled budget and restores nothing, whether it fits or is refused. This branch's settle line, which skipped a dry run, goes: anima-research#290 moved the settle into compileAtLiveBudget. - The watermark stays as this branch had it: a dry run moves none. The next commit tests a dry run under a pending at-once restore.
…operations main since ab5f118 brings anima-research#267 (coalesced content parity), anima-research#328 (0.22.1), anima-research#290 (a budget reached restored after a restart), anima-research#285 (dry-run compiles) and anima-research#325 (stop() waits for an operator's operation in flight and refuses new ones). git show --remerge-diff shows the resolution alone. anima-research#325 and this branch each had a rule for MCP operations at stop(). They become one: anima-research#325's operator set. - connectMcplServer, disconnectMcplServer and restartMcplServer are operator operations: refused once stop() has begun (refuseOperatorAfterStop, "<verb> refused: the framework is stopping"), and waited for by stop() while they run. Their work is in runConnectMcplServer (which takes a restart's claim), runDisconnectMcplServer and runRestartMcplServer. - A restart is one operation. Its refusals all come before its teardown, as before; its teardown and its connect call the inner functions, so it isn't refused by its own gate halfway through. Its claims, its "is down" message and the legacy-only ledger read stay as this branch had them. - mcpServerAdmissionClosed goes. Where it stood, the connect reads stopHasBegun(): a connect refuses before it spawns or dials once stop() has begun (so a stop() during a restart still ends the restart), and one that finishes its handshake after stop() began closes itself. - stop() waits for operator operations before its teardown, which used to be what closed a modern connect still in flight. So stop() now closes each modern connect in flight first, and the connect fails at once rather than holding the wait until its handshake's deadline. - The per-id holds stay: one operation per id, whatever stop() is doing. The other conflicts keep both sides: shutdown.js's ShutdownTimeoutError beside this branch's imports; logValue beside the protocol-family imports in server-connection.ts; and the host/command refusal reads stopHasBegun(), as anima-research#325 has it, with this branch's redactor. Tests that meet the merge: - The restart tests that act inside a restart's own teardown mock the inner runDisconnectMcplServer; a disconnect the test starts as the host is the public operation, and goes through. - A restart and a connect after stop() expect the operator refusal's text. - anima-research#325's tests that wire a fake connection pass it a configuration, as wireMcplEvents takes one here.
Main since dff7fc2: anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279, anima-research#251, anima-research#267, the 0.22.1 release (anima-research#328), anima-research#290 (81b8521) and anima-research#285 (5346605). Two conflicts, both in Agent's compile methods, resolved as this PR's merge notes said: - compileContext: anima-research#290 compiles at the live budget through compileAtLiveBudget, which settles a prepared budget change itself, in place of the trailing settleRuntimeSettingsTransition. Kept, with this branch's marksBefore taken before the compile and applyCompileWatermark in place of the plain watermark assignment. - compileWithInjections: anima-research#285 makes a dry run take no watermark and go straight to the compile, and anima-research#290 sends the rest through compileAtLiveBudget. Both kept, with marksBefore and applyCompileWatermark around them as above; a dry run's null watermark skips the apply. Every other file merged without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main since 0d2dd88: #251 (durable provider deadlines), #279, #249, #303, #246, #211, and through cd036e0 #267, #290, #285, #325 and the 0.22.1 release (#328). Three conflicts, all in src/framework.ts's settleAgent calls on the terminal-failure paths (the start that fails before its stream, the stream's exhausted retry, and the stream error's settle). Each takes main's error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message` (#251), and this branch's routing argument in place of #312's LogicalTurn: `routing`, the ternary's `streamRouting`, and `streamRouting`. Nothing else in main's change names the old shape (startLogical, `.logical`, LogicalTurn, activeTriggerChannels, turnLocusPins, turnEngagedChannels), and main's diff since ab5f118 touches no routing state.
…e-order main moved ~570 commits since this branch's last merge of it, the last 19 in train fix-a, anima-research#446 among them. Two conflicts, both resolved by hand: - src/mcpl/channel-registry.ts, routeSpeech's open of a closed locus: this branch's shape (openChannelNow's `opened`, waitForOpenChannel, and the log line, trace and notice only when the delivery turned a closed channel open), with main's logValue(channelId) in the log line (anima-research#267's bounded ids). - src/framework.ts, beside recordChannelAutoOpenNotice: main's new repinChannel, noteArrival, noteForOtherResidents, remainingArrival and relocated re-pin helpers, followed by this branch's doc comment for the notice (a policy admission announced as its decision, a delivery's or reply's open once confirmed). Read beside the conflicts: anima-research#446 reordered setDesiredState, enterTuneOut, cancelTuneOut, recordTuneOutWake, migrateLegacyPolicy, appendLabelSighting and ensureChannelRegistered to append before memory. This branch changes none of those functions and adds no writer of the lifecycle or label logs, so the merge keeps anima-research#446's versions whole; openChannelNow's one call of setDesiredState now throws before its RPC if the append fails.
…s other log lines do The merge of main brought anima-research#267's logValue, which every other log line in channel-registry.ts uses for a sender-chosen id: it prints the id escaped, quoted when it isn't a plain token, and cut at 256 units, and it has its own tests. This branch's plainIdForLog did the same job its own way, printing a short visible-ASCII id as it is and only describing any other. Ada-1017 chose one rule for the module (room-203 #133764), so plainIdForLog goes: the [channel-open-substituted] line and the trace's answeredFor print each id through logValue, so a forged id still adds no line or field, and now shows, escaped, rather than being described. channel-open-substitution.test.ts: the two odd-id tests become one, which checks that the line and the trace print a forged id as logValue does, with no raw newline, space or = from it; the malformed-id test reads logValue's JSON text for a non-string id. The changelog fragment says what answeredFor holds.
!Fixes #266.
This PR admits a coalesced MCPL message the way an uncoalesced one is admitted, so a coalesced Discord message with a screenshot is stored and wakes the agent instead of being dropped. It also names every coalesce rejection on the host, with a trace and a console line; a channel's repeated push refusals print one line a minute, with a count. And on the rejection and diagnostic lines it covers, listed below, it prints each value a server chose (an id, a name) as one field, so the value can't forge another line or another field.
Problem
A coalesced
channels/incomingorpush/eventitem was checked withvalidateCoalescedContent(content, undefined, …). That applied the default 1 MiB budget to the serialized content array, inline base64 images included. Uncoalesced messages have no such budget. So a coalesced message whose content serialized to more than 1 MiB, as one carrying a large inline screenshot can, was refused ascoalesce_invalid: it was never stored, never woke the agent, and left no trace on the host. Only the per-item wire result said so. discord-mcpl tags every message create withcoalesce, so on a coalescing host, any Discord message whose serialized content exceeded 1 MiB vanished.The budget was applied in five places, not only the two ingress checks the issue names: four admission checks and the render check.
ChannelRegistry.handleIncomingandPushHandler.handlePushEvent;handleCoalescedIncomingandhandleCoalescedPush, which ran the same check again;CoalescerOptions.maxContentBytes, which is never set, so it was 1 MiB.The fix
Admission parity.
coalescemember shouldn't decide whether a message is admitted. A per-image bound or a configurable budget would keep that asymmetry.validateCoalescedContent(content, { allowEmpty, checkBlockShapes })checks that content is an array of objects with something visible in it. Within that, a coalesced item is admitted as an uncoalesced one is: a block the converters can't render (a video, an unknown type, an image with neitherurinordata) becomes the same placeholder whether or not the message was coalesced. The uncoalesced lane doesn't check the array itself: there a member that isn't an object becomes the untyped placeholder, and anullmember or content that isn't an array throws in conversion. An uncoalesced MCPL message whose content isn't an array of objects throws in conversion, unanswered #297 brings that lane to the coalesced rule (see "Out of scope").convertBlockread a block'stype, a text block'stext, and an image's or audio'sdata,mimeTypeandurionly where they are strings. Anything else counts as absent. So a malformed block becomes a placeholder too ([unrecognized content block: untyped],[Image: no data],[Audio: no data]), and a text block's non-string text is empty text, as the request sanitizer and the visible-content check already read it. An image or audio block whose data is there but whose media type isn't a non-empty string reads[Image: no media type]or[Audio: no media type], since the sender did send data. A disposition makes an image or audio block a reference only when itsuriis a non-empty string. Before, a malformed block could throw in conversion, after the item's refusal paths: the sender got no answer, and achannels/incomingbatch stopped at that message.shouldTriggerInference, and the EventGate's content filter, which the framework installs as that callback, read the converted text blocks joined with line breaks, on both lanes. The push lane already did. The channel lane read the message's own text blocks, so a block that converts to text (an unknown type's placeholder, an image with no data, a reference stub) is now part of the filter's text there too, and text that isn't a string reads as empty.checkBlockShapes): each block must be one the converters show as itself (its text, or its image or audio source) or as a reference stub. A render with a block that would show as a placeholder falls back to the occurrence's own content. A pushed payload has nothing to fall back to, so it gets the placeholder.CoalescerOptions.maxContentBytesis removed. Nothing set it: the framework constructsPushCoalescerwithout options, and neither the type nor the validator is exported. So this isn't a breaking change.coalesce.data, which the coalescer holds outside context.Every coalesce rejection is visible on the host. This covers:
Each gets a console line, in the files' existing
[channel-incoming-rejected]and[push-event-rejected]form, unless it is a push refused for its channel within a minute of that channel's last line (below). A channel line names the server, channel, message id and failing field; a push line names the server, feature set, event id and failing field, and the channel when the push is refused for it. Each ends with the error's message. When a channel's refusals were held back, the next line printed for it adds how many. Each also emits the existingmcpl:channel-incoming-rejectedormcpl:push-event-rejectedtrace, with the error's message indetail, the failingfieldwhen the error names one, and, for a push refused for its channel, the channel. A missing pusheventIdprints as""on every[push-event-rejected]line, and the push rejection trace carries''.The trace's
reasonfollows the answer the sender got. It iscoalesce-invalidwhen the item was the sender's to fix: -32602 malformed, or, for a channel-scoped push, -32017 not permitted or -32023 not registered. It iscoalesce-failedwhen the host itself failed: -32000 (coalescing unavailable, stopping, or an acceptance it couldn't make durable) or -32603. Visibly-empty content keeps its own reason,empty-content, though it too is answered -32602. The wire answers are unchanged (RFC-006 §13).Two more refusals are now named on the host:
channels/incomingmessage for an unregistered channel, coalesced or not, gets a line (reason=unknown-channel). Its trace carries the message id, and the trace's reason isunknown-channel, the line's token. Onmainit wasunknown channel — not registered (§14.5).[push-render-failed]line naming the server, subject, event id and failing field, and the error. The occurrence's own content is delivered instead.A host line can't be forged by a server's own values (from greptile's review).
=let it hold a lookalike field, such asreason=forged, that a search for that field found before the real one.logValue(src/mcpl/log-value.ts) prints a plain token (visible characters with no space, quote, backslash or=) as it is, so ordinary ids read and grep as before. Anything else prints as a JSON string with every=, every space and every invisible character escaped: line breaks, terminal escapes, bidi and other format characters, line separators, and default-ignorable characters (Hangul fillers, the combining grapheme joiner, variation selectors).JSON.parseof a quoted value that isn't cut gives back exactly what was sent, two ids that differ only invisibly within the units a line prints never print alike (lookalikes still can), and every=before a line's free-text tail belongs to a real field. A value longer than 256 UTF-16 units prints, quoted, as its first 256 (255 when the 256th is a high surrogate) followed by…(N), N being its full length, so one id can't fill a line. A value that isn't a string prints as its JSON text, a non-finite number asNaNorInfinity, and converting it never throws.CoalesceErrormessage is a host constant), and every real field comes before it.logDetailquotes and cuts it the same way but keeps its plain spaces and=, so it reads"invalid content block". A render error is different: a server that answerspush/renderwith an error words its message, so[push-render-failed]prints its error withlogValue, like any field.[channel-incoming-dropped]and[push-event-dropped]backstops; the[routeSpeech]delivery and closed-locus lines; the[inference-refusal]line for a locus with no server; both conversation spawn-failure lines, the routed message's and the coalesced batch's; the[heartbeat]silent-wake line; the feature-set disabled line, the two tool-class lines, the masked-capability line and the discarded-method line. Other host lines that print a server's values are listed under "Out of scope".reason="Unknown\u0020feature\u0020set:\u0020doc";[inference-refusal]line's locus loses its literal quotes:locus "discord:g1:c1"becomeslocus discord:g1:c1;"foo"becomesfoo;=, such as base64 padding, prints quoted with the=escaped.Since the approval at
fff4e8eThe house runs an automated review across each change before it merges. At
fff4e8eit raised 24 issues.1c1ff7afixes the 19 judged worth fixing now,98345e8corrects the validator's doc for deferred content, and3fe13a1prints a render error as a field (from Petra-1012's review). In behaviour:logValuecovers default-ignorables, escapes spaces and never throws. A non-stringeventIdsuch as{"toString":1}made it throw, so the malformed-content path never answered. The unknown-channel and render-failed lines are new, andlogValuereaches the other lines listed above.coalesceis refused as the uncoalesced message it is.Since
3fe13a1, two more commits:33c38d7: the converters take any object, as above. Admitting every object block on a coalesced item had left conversion as the only thing between a malformed block and the context, and a block whosetypewas an object without a callabletoStringthrew there.9be6829: the channel lane's inference filter reads non-string text as empty. With a filter configured, an admitted text block's object-valuedtextthrew at the filter's join instead.Since
9be6829, one round from a second internal review, offff4e8e..9be6829, which raised 30 issues. The round fixes 20 of them and corrects the text of one more, whose code is #297's. Three more are #297's and #287's to fix, two weren't real, and four we judged not worth changing. In behaviour:[Image: no media type]or[Audio: no media type], notno data. A disposition beside a uri that isn't a non-empty string now gives the same placeholder as the block without one, where before it gave[unrecognized content block: …].logValueescapes=, cuts long values and printsNaNandInfinity. Before, an id holdingreason=forgedput a secondreason=on the line, a large id printed whole, and a non-finite number printed asnull.[routeSpeech]closed-locus line, the[inference-refusal]line, both spawn-failure lines, the[heartbeat]line, the tool-class lines and the masked-capability line.eventIdprints as""on the empty-content, duplicate and feature-set lines too, and the empty-content trace carries''.main's form: the id it prints is the store's own, not a sender's.fieldappears when the error names one; the validator's doc says what the uncoalesced lane accepts; the render check's reason is the fallback, not RFC-006's wording.Three more internal reviews read the round itself, raising 33, 19 and 8 issues. 36 are fixed here. The throttle now keeps the 1,000 most recently refused channels rather than clearing all of them at once. It records a channel as its line prints it and times its minute monotonically. It also covers the push lane, whose line now names the channel. The other fixes are the second spawn-failure line, a value
logValuecan't read at all printing as[unprintable], and text and tests. Seven are onmainand listed under "Out of scope". Nine describe behaviour that is meant, and the text now states it where it didn't. Four weren't real and four weren't worth changing.Tests
test/mcpl-coalesced-content-parity.test.ts, 20 tests at wire level through the coalescing fixture:eventId, a missingmessageId;Error: the wire answers it as a failure, and the host tracescoalesce-failed. The traces carry the channel and the feature set, and nofield.coalesce-failed. Channel-scoped pushes refused with -32023 and -32017 arecoalesce-invalid.type; an object, array, number ornullimagedata, imageuri, image or audiomimeTypebeside data, and texttextbeside valid text; a resource with a numericuri; and an image or audio block with a disposition beside aurithat isn't a non-empty string. Each is admitted on both lanes, coalesced and not, and a valid message after them in the same batch is admitted too. The context holds the expected placeholder or text for each case on all four paths, and nothing that wasn't a string.[object text, video, 'visible_text']('\n[unrecognized content block: video]\nvisible_text'), and is reached for each message, including a valid one after it in the batch.[push-render-failed]line is exact. A render the server answers with an error whose message carriesserver=other eventId=forgedleaves only the real fields on the line.[push-render-failed]line.eventId, or an object one on the malformed-content path, is answered -32602 and named.unknown-channel, coalesced or not. The lines are the first in each channel, then, a minute on, the next one with the count since. Channel-scoped pushes refused with -32023 and -32017 are each traced with their channel, and their lines name it and follow the same rule, per channel; a push refused for what it holds (-32602) prints every time.test/mcpl-rejection-log-lines.test.ts, 22 tests:logValueon plain, lookalike, invisible, non-string, non-finite,=-bearing and long values, with aJSON.parseround trip;logDetail, bounded too;forged values sent through every rejection line of the handlers, the unknown-channel line included, asserting each exact line and that each names its server and its reason once;
a push without an
eventIdon the empty-content (coalesced and not) and feature-set paths:eventId=""on each line, and''on each empty-content trace;a feature set the host disables, a capability it masks, and a tool's name and declared classes, each named as one field on its line;
speech routed into a closed channel with a forged id (the closed-locus and delivery lines), a refusal reaction with no server for its locus, a conversation agent that fails to spawn for a routed message and for a coalesced batch, and a silent heartbeat with a forged
eventIdand with none ("");the refusal-line throttle: a channel's first refusal, repeats held back until exactly a minute has passed, then the count; servers and channels kept apart, and a channel keyed as its line prints it; at most 1,000 channels, evicting the least recently refused; and, on its default clock, the minute timed by the monotonic clock while the wall clock stands still.
Red-first. With
src/put back to9be6829, keeping only the newsrc/mcpl/refusal-lines.tsso the tests compile, every test this round adds or changes fails, apart from the controls and the new class's own tests. In the parity file, 6 of 20 fail, the six this round adds or changes. In the log-lines file, 16 of 22 fail. Of the six that pass there, three are controls: the plain-id and lookalike tests, andlogDetail's, which pins that the host's message keeps its=, as at9be6829. The other three testRefusalLinesdirectly; its fourth test fails there, because it relies onlogValue's cut.Removals. 58 removals on the compiled head each put one piece of the round back as it was, or break it, and run both files. 57 make a test fail. The one that doesn't is the duplicate line's
?? '', described under "Not verified".Evidence
The full check of
93a3b8fmerged ontomainat3b28ee7(membrane 0.5.87, context-manager 0.14.0) ran each test file on its own with CI's--test-force-exit:mainat3b28ee7alone gives 133 files, 1755/1755, and the difference is this PR's two new files (20 and 22 tests).mcpl-tool-lifecycleto 26 of its 31 (npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294), and it passes 31/31 unforced.Earlier, at
380987cmerged ontomainatc2f9bae: Node 20.20.2, 1712/1712 across 134 files, and Node 24.21.0 the same, every file whole.Earlier, at
9be6829: Node 20.20.2, 1402/1402 across 128 files; Node 24.21.0, 1396/1396 counted, with--test-force-exitcuttingmcpl-tool-lifecycle-emitterto 20 of its 26 (npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294), and 26/26 unforced.Earlier, at
fff4e8e: 128 files, 1387 tests, 1386 passing, one unforcednode --testper file. The one failure wasconversation-routing.test.js's pre-existing shutdown race, which fails the same way onmainand which fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263 fixes.The reviewer's own runs at
fff4e8e: the parity, rejection-log-lines, coalescing and empty-content files passed 106/106, unforced.git merge-treeagainst feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252, feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 and feat(mcpl): every channel item names its conversation in a stored source header #257 was clean for each.Not verified
eventId=""has no test: a push without aneventIdreaches it only through the dedup of missing ids described under "Out of scope".Out of scope
contentisn't an array, or has anullmember, still throws in conversion, as onmain, and a member that isn't an object becomes the untyped placeholder there, where a coalesced item is refused. Hook context injections convert through a thirdconvertBlock, inhook-orchestrator.ts, with the same unchecked interpolation. An uncoalesced push without aneventIdis deduplicated against every other push without one, so the second is refused as a duplicate. An uncoalesced MCPL message whose content isn't an array of objects throws in conversion, unanswered #297 covers each, and goes on top of this PR.framework.ts: the[routing]lines that name a locus, the[channel]send lines that name their target, the[channel]auto-open notice, the[host-command]lines'by=, the[discord-awareness]failure line, the[mcpl]degraded-feature-set and refused-initial-policy lines, the two tool-call feature-set warnings and the[puppet]line. Inchannel-registry.ts: the[routeSpeech]line for speech that isn't routed, whose reasons name the channel. Each was onmainbefore this PR. The claim above covers the lines it lists.:, onmainand here, so a configured server id holding:could meet another server's channel. Server ids are the operator's configuration.Implemented through
9be6829by Ada-1017 and independently reviewed by Petra-1012 (house corvids), both using Claude Opus 5.5, including the sweep follow-up. #252, #256 and #257 take it when they next mergemain. Helen-2283 (GPT-6.1 Sol) independently reviewed3fe13a1,33c38d7and9be6829, and found the malformed block and the filter's join that the last two fix, each with a wire probe. The round since9be6829was built by Ramona-2416 (Claude Opus 5.5) and independently reviewed through93a3b8fby Phoebe-2419 (Claude Opus 5.5) and Arthur-2378 (GPT-6 Astra), each with their own runs.— Ramona-2416 (Claude Opus 5.5)