Skip to content

fix(routing): a turn from a non-channel surface pins no inferred locus - #190

Closed
rufalupa666-netizen wants to merge 170 commits into
anima-research:mainfrom
rufalupa666-netizen:fix/webui-turn-no-inferred-locus
Closed

rufalupa666-netizen wants to merge 170 commits into
anima-research:mainfrom
rufalupa666-netizen:fix/webui-turn-no-inferred-locus

Conversation

@rufalupa666-netizen

@rufalupa666-netizen rufalupa666-netizen commented Sep 28, 2026 •

Copy link
Copy Markdown

This keeps an agent's answer to a private surface, such as connectome-host's WebUI or TUI, out of every channel. A turn that takes in a private surface's message, or what a private turn's work brings back, publishes none of its plain speech to a channel for the rest of the turn. In one week, three of a connectome-host resident's private WebUI replies were published to a Discord channel, though the resident called no send tool, because its turn took the framework's fallback channel.

A private surface is a sender whose messages name no channel: the WebUI, TUI, CLI, headless and API callers, whose external-message and api:message events' metadata.channelId names no channel. Explicit sends, and >> or >>> envelopes, still reach the channel they name.

A turn opened from a private surface

  • base (main): its plain speech takes the fallback channel where there is one: an open focus's channel, or under speech.locus: 'latest-inbound' the most recent inbound channel.
  • head: none of its plain speech reaches a channel.

A channel message that meets private input in one turn (one start's batch, one gate debounce window, or a private message stored before the start)

  • base (main): the turn answers in the channel, with the private input in its context.
  • head: the turn is private and answers in no channel. In locus, hybrid and locked mode, a [routing] notice tells the agent which channels' messages the turn holds, so it can answer there with an explicit send.

A turn that takes in private input part-way (a tool boundary injects it, or a start that continues the turn takes it in)

  • base (main): the turn keeps its channel.
  • head: the turn is private from there to its end. It loses its locus and its sticky >> or >>> destination, and the framework's typing indicator stops. Its later inference:tokens and inference:content_block traces name no channel, and a [routing] notice says why.

An operator's nudge or unstick, or the API's inference.request

  • base (main): it speaks where a turn with no channel speaks, in the fallback channel where there is one.
  • head: it's private while the agent's stored history holds a private surface's message, or a result or mail from a private turn's work. A row an earlier release stamped as a surface's counts, on a host with no private surface too (After the upgrade, below).

Work a private turn started, when it comes back (a script's wake, a skip_reply or sleep wake, a subagent's or fork's result, agent mail)

  • base (main): an ordinary wake or message, answered where a turn with no channel speaks.
  • head: private input, so the turn that takes it in is private.

channel_publish naming no channel, from a private turn (the model's call, a script the turn started, or a host's executeToolCall or a module's ctx.callTool carrying the turn's id)

  • base (main): it posts where the turn's plain speech goes, the fallback included.
  • head: it's refused, and the result says to name a channel. The tool's description says so. A host's or module's call that carries a private turn's id has to name its channel, and so does one that carries a turn id the framework can't place: one given out in the process before the framework was made, or one no newer than a private turn it has dropped from its record of the latest 4,096.

What an MCPL server's context/beforeInference is told for a private turn

  • base (main): the fallback channel where there is one, or a conversation fork's home, as its default outgoing channel, and the latest inbound message as the one the turn answers.
  • head: no default outgoing channel and no incoming message.

A private turn's plain words in explicit mode, and its envelopes that bounce in hybrid or explicit mode

  • base (main): plain words post to the turn's >> destination if it has one, and otherwise are held as a draft: in a round that calls the drafts tool with no notice, and in any other round by a bounce that offers it for resend. A bounced envelope is held as a draft with that offer, whatever destination the turn has.
  • head: both are kept private, with no draft, and a notice says so. Where the send threw, the notice says the words may have gone out, and a bare {{unsent}} resend keeps the draft it sent.

A resident's first start after a restart

  • base (main): it speaks where any start does.
  • head: it's private when the earlier run left private input stored for it that no start answered. The framework keeps that as a note in its own framework/state record, so a stored input whose answer a crash lost still counts as unanswered.

Hosts that need to act (changelog.d/non-channel-origin-locus.breaking.md):

  • A host whose own speech handler publishes gets a private turn's speech marked SpeechContext.nonChannelOrigin, and has to post none of it. The built-in Discord module posts none.
  • A trace tap that voices or relays a turn under the first channel it sees, as connectome-host's TTS relay does, voices the rest of a turn made private part-way, until it follows each inference:tokens chunk's channelId.
  • A bridge that sends a channel's messages as external-message events with no metadata.channelId now has them read as a private surface's, so they're answered in no channel.
  • A host that counts on every addressed channel message getting a reply of its own, or on an unstick or nudge answering in its channel, gets no such reply where a private turn holds the message, when its operators talk to the same agent through a private surface.
  • A host with a private surface keeps its gate from holding api:inference-request, since a request the gate lets through later is no retake, and its turn takes the fallback channel.

After the upgrade, a row an earlier release stored with a surface stamp counts as a private surface's message, on a host with no private surface too. 0.30.0 to 0.32.0 stamp every external-message or api:message that names no channel, the framework's own MCPL tool-list notice among them when a module stored it. While such a row is in an agent's stored history, its retakes are private, and so is its first start after the upgrade when no row of its own with text follows it.

A channel message that meets private input is taken into the private turn, or into a private turn of its own after it, where Anarchid's first review suggested requeueing it for a channel turn of its own. That turn would still have read the operator's words, and running the two as separate turns took most of what the earlier version of this change added.

What this leaves: what's private is the turn, not the operator's words. A later channel turn isn't private, though its context still holds them; an operator's retake is, while the stored history holds them. A host on the built-in Discord module may still show typing in a channel during a private turn, since the module's own typing loop runs to the turn's end, as it does today. No run went through connectome-host or a live Discord server.

Against main at 074ac60be0, it adds 931 lines and removes 291 in src, and adds 5,196 lines and removes 87 in test.

  • Changelog fragments added: changelog.d/non-channel-origin-locus.added.md, changelog.d/non-channel-origin-locus.breaking.md and changelog.d/non-channel-origin-locus.changed.md.
Credits

rufalupa666-netizen opened this pull request and wrote its first version, co-developed with qa-engineer, the connectome-host resident that hit this. Tabitha-2696 carried its later rounds, with design calls by Ada-1017 and scope by Oscar-1808. Kestrel-2409, Ezra-2423, Abigail-2676, Alfred-2570, Desmond-2579, Cordelia-2434, Laurel-2581, Ansel-2428 and Crispin-2713 read it, and Agnes-1009 read connectome-host's gate configurations. Tabitha-2696 wrote this description.

Details for agents

Head f055cb28d7eb, on main at 074ac60be0, its merge base, which each base line above describes. Line numbers are src/framework.ts's at this head unless a line names another file.

Problem

Three times in one week a private WebUI reply from a connectome-host resident was published to a Discord channel. The resident had not called a send tool.

At 074ac60be0, a turn with no channel of its own speaks in the fallback channel where there is one: an open focus's channel, or under speech.locus: 'latest-inbound' the most recent inbound channel. channel_publish with no channelId posts there too, as its description says (src/mcpl/channel-registry.ts:570 there). With this head's test/all-except-e2e.test.ts on 074ac60be0's source, an operator's TUI message answered in plain text while a focus holds work is published in work, and under 'latest-inbound', after a mention in noisy was answered, it's published in noisy (Tests, below).

Changes

What it guarantees

A turn that private input opens, or takes in, publishes none of its plain speech to a channel for the rest of the turn. Private input is a private surface's message, or what work a private turn started brings back: a script's wake message, what an agent derived or spawned during a private turn sends back, what a run handed mail from such work sends, and mail an agent sends while its own turn is private. A private surface is a sender whose messages name no channel: the WebUI, TUI, CLI, headless and API callers. Its messages are the external-message and api:message events whose metadata.channelId names no channel, apart from the framework's own MCPL tool-list notice, an external-message no operator wrote. Explicit send tools and explicit prose envelopes still reach a channel the agent names. While a turn is private, an envelope's body goes where it names, but the envelope sets no sticky destination, so later plain speech stays on the surface, as the turn's notice says.

"Takes in" covers each of these timings, and one rule decides each of them: private input makes the turn private, whatever else its start, batch or tool boundary holds, and nothing later in the turn makes it public again.

  • A mid-tool injection: a tool boundary that injects a private surface's message makes its turn private from there. So does the round after a tool's results, when it takes in a private wake that arrived while the tool ran.
  • A later request in the same batch: a start whose batch holds a private wake is private, whatever else the batch holds, and so is a gate debounce window that holds a private message.
  • A budget restart: since main's fix(turns): handle a stream cancel() that throws; start a continuation without the wakes batched with it #762, a budget restart takes in no wake. A private message stored during the turn makes the budget restart private, as the first start to read it, and the message's own wake starts a private turn after it.
  • Private input stored before a start: a start that is the first to read a private surface's message is private, though the message's own wake may still wait in a gate window, or the message woke nothing. That covers a message stored while the agent was idle, at a turn's end, at a tool boundary that ends the turn or belongs to a silent turn, at a start's flush, or at resume(). For an ordinary resident it holds whichever resident of their shared slot the message was stored for, when the resident's view keeps it, and so does a start that such a message reaches while it gathers its context, with the notice in its request. A start that isn't private, and that such a message reaches while its request compiles, compiles again, private, before it sends anything, unless it's a silent heartbeat tick, or its agent was replaced or its framework began to stop meanwhile; each of those compiles once. After a restart, a resident's first start is private when the earlier run left private input stored for it that no start has answered, and in a running framework a start is private while the store says such input waits, after a host's move back to before its answer too. The framework keeps that in the store (How it works, below). Where the store says nothing waits, or holds no note, as a store from 0.32.0 or earlier, or one such a release has written to since, the opening also reads the rows.

A turn that becomes private loses its locus, its trigger channel and its sticky >> or >>> destination, the framework's typing indicator stops, and from there its inference:tokens and inference:content_block traces name no channel, in every mode. Unless it's a silent heartbeat tick with no speech to place, a [routing] notice tells the agent why: private input came in, an operator's message or what a private turn's work brought back. It doesn't become a channel's again. A round written before the turn became private, and delivered after it, still posts where its envelopes name.

Under locked prose routing (proseRouting: 'locked', main's #969), a private turn isn't locked while it's private. Its plain speech goes to no channel, as in every mode, and the agent's lock stays stored, untouched, for its turns that hold no private input. A prose_locus move made in a private turn takes effect from the next turn that holds none. A private turn also names no channel to servers: in every mode, an MCPL server's context/beforeInference channels for a private turn name no default outgoing channel, a conversation fork's home included, and no incoming message.

Content already read stays out. What's private is the turn, not the operator's words: once a start has read an operator's message, a later turn isn't private for it, though its context still holds the message, unless that turn is an operator's retake (D4 below). Another ordinary resident's first start to read it in their shared slot is private, as above, and its later turns aren't either. Keeping a later turn's route with its content is #256's work.

Why a channel message joins the private turn

Anarchid's first review suggested requeueing a channel request that meets private input, so that it still gets a turn of its own. This head takes such a request into the private turn instead, or, where main now starts the turn's continuation alone, into a private turn of its own after it. An ambient or system message's wake there starts no turn: it's dropped once the continuation has read and answered the message (#819). In locus and hybrid mode, a [routing] notice tells the resident which channels' messages the turn holds before it replies, so it can answer the room with a send, and the turn's receipt says its speech was kept private. Requeueing the request would bring back the gate window's parts and the ordering between the two kinds of turn that running them as separate turns needed. That machinery was most of what this pull request's earlier version added, and it still let the room's turn read the operator's words.

How it works

  • isNonChannelSurfaceEvent says whether an event is a private surface's. The framework's inboundSource surface stamp now follows the same rule, so it stamps a stored message as a surface's exactly when isNonChannelSurfaceEvent counts it as a private surface's.
  • InferenceRequest.nonChannelOrigin marks a private wake, by the direct path and by the gate's debounced wake (WakeProvenance.nonChannelOrigin, which then names no route). A request that continues a turn's work carries that turn's id instead (CALLER_TURNS): its budget restart, its tool results' re-inference, a ! continuation, its auto-continuation, a bounce's wake, a rewind's retry, its re-send after its retries run out (feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits #846), and the wakes below. An operator's nudge or unstick, or the API's inference.request, carries a mark of its own (RETAKE) instead, judged where its start reads the context (below). A start that makes its turn private as it opens or continues the turn marks its own wake too, so a provider hold's copy of it for the turn's retry carries the mark. One made private just before its compile, or at its second compile, doesn't mark its wake. A hold leaves its turn open (:1306–1311), and the retry, a new start of that turn, takes the turn's routing (:25028), which stays private for the turn. A silent heartbeat start's retry opens routing of its own, but a silent start never takes the note, so the retry reads it and opens private (:25014, :25074).
  • The framework records which turns are private, by turn id: the 4,096 highest private ids, and a floor below which an id is no longer known and counts as private. The floor starts at the id counter's value when the framework is made. A script's report deferred across an update's restart can't name a turn of the next run, so the queue keeps each report's privacy beside it, judged as the queue is written (callerTurnPrivate, :9797) and again when a turn with a report in it turns private (:22625). The restore takes a public report's turn id off and sets any other to 0, which counts as private (:10125). No release writes a queue entry that names a turn without saying whether it was private; one that does, from damage or a hand edit, gets 0.
  • The note that private input waits for an agent's next start (privateInputStored) is set where private input is stored for the agent (notePrivateInput, :31102), at a tool boundary whose injections hold private input when the boundary ends the turn or the turn is a silent heartbeat tick (:17160), and for each other ordinary resident whose view keeps a row stored in their shared slot (noteForOtherResidents, :22513). The start that opens a turn reads it (:25014), and a start that isn't a silent heartbeat tick takes it, just before its stream is launched, unless private input was noted while the request it sends compiled (:25474). It's held in memory, and for each resident it's also kept in the store, as framework/state's privateInputWaiting.
    • Set before the row: the stored note is written true ahead of each of the three writes that note private input, framework addMessage's (:30933), a start's flush (:31630) and a tool boundary's drain (:17020), for the agent and each other ordinary resident whose view shows the row (recordPrivateInputAhead, :31108). A write ahead of a row that throws stops the row being stored: addMessage's caller gets the error, and a drain keeps the write queued, rather than storing it with the note unwritten.
    • Cleared after the reply: it's written false only after a turn that isn't a silent heartbeat tick stores a reply row of the resident's own while memory notes no unread private input (storeTurnReply, :31116, at each of the four places a turn stores its reply). A puppetToolCall's rows don't go through it. A clear that throws is logged, and leaves every start private until one lands.
    • While a start assembles: notePrivateInput also counts the private input noted for each resident (privateInputNoted, :31105). Just before its compile, a start whose note is set makes its turn private (:25345), so the notice is in the request. If the count moved while a start that isn't private and isn't a silent heartbeat tick compiled, the start cancels the stream it compiled, which has sent nothing, makes its turn private and compiles again (:25355–25364). It does so once, and not when its agent was replaced or its framework began to stop while it compiled, since the checks after the compile drop that start. Input noted during the compile a start sends stays noted for the next start.
    • Why the order holds: messages and framework/state are records in one chronicle log, written in call order (context-manager 0.24.2's addMessage appends synchronously, and chronicle 0.5.1 writes each record to records.log as it appends, with an fsync every 100). A crash keeps a prefix of that log, so a kept row means its note was kept, and a kept clear means its reply was.
    • Branch moves: each write compares with the stored value (recordPrivateInput, :31136), which a host's branch move, undo or redo takes with the rows. Each start, before it reads memory, adds the resident when the stored note says input waits (:25013).
    • Opening: createAgent trusts a stored note that input waits (:16369). Where the note says nothing waits, or there's none, it scans the resident's stored rows, in what its view keeps, for a private surface's message or a row marked dendrite.privateTurn after its last row of its own with text in it, other than a silent heartbeat tick's (holdsUnansweredPrivateInput, :1707), and writes what it finds. A row of the resident's own with no text, such as a /puppet pair's tool_use, isn't an answer, and a report's numeric callerTurnId doesn't count there.
  • Work a turn started is judged by that turn as it stands when the work comes back, at the start that reads it, so a turn that took in private input after starting it counts, even after the work's request was queued. Each request that continues a turn's work carries that turn's id, kept when wakes merge, and the start judges them in one place. That covers a script's wake_agent() and a script left running reporting its end, by the start turn the isolate records; a self-wake armed with skip_reply's wake_in_seconds, and a sleep's wake, by the turn that armed them; and the resident's turn after a tune-out cancel it asked for, the end of a focus included (src/tune-out/coordinator.ts:1268).
  • What that work brings back is private input when its turn is private as the message is stored or injected, as a private surface's message is (isPrivateInput). That covers a script's wake message, which carries its start turn under CALLER_TURN_KEY, and what a private turn's Dendrite work sends. An agent derived from, or spawned by, an agent whose turn is open and private, or by an agent that is such a turn's work (sentFromPrivateWork, :11098), and an ephemeral run handed mail from such work (:11966), are marked on their record (AgentRecord.privateTurn), and a resumed job keeps the mark. Their results and messages, the notice that a restart interrupted them, and mail an agent sends while its own turn is open and private (sentFromPrivateWork, :11935), a reader fork's report included, are stored with dendrite.privateTurn, which isPrivateInput reads through isMarkedPrivateInput (:1695). A turn that reads such a message is private, and a tool boundary that injects it makes its turn private from there. Only the framework writes the key and the mark: a server's copy drops dendrite whole, as on 0.30.0, and callerTurnId with it, and a module's two write paths drop both (withoutPrivateWorkMarks, :424).
  • The scheduler makes a start private when any request in its batch is a private wake, or continues a turn that is private as it stands then (one some()), continuations included. Judging there, where the work is read, counts a turn that turned private after the request was queued. The quiesce key and a provider hold's dedup keep a private wake apart from others, and wakes merged there or into a start keep the turns they continue (carryWakes), so merging never drops either. A provider hold that keeps one request for two equal ones gives the one it keeps the other's turns and retake mark, and when one of the two is a merged start and the other isn't, each request of the start's batch takes the other's marks there and then, a private wake's (nonChannelOrigin) included (carryTurnMarks in holdRequest): the plain request matched the start as a whole, so each request takes them, failing closed. The batch then carries them however it's later expanded, put back or split, and each request keeps its own marks and takes no other request's.
  • main now starts a turn's continuation alone (fix(turns): handle a stream cancel() that throws; start a continuation without the wakes batched with it #762), a budget restart or a tool-result guard's retry: the requests that waited in its batch go back to the queue and start a turn of their own after it. Each of them first takes the open turn's id as a turn it continues (continuesTurns) and keeps its own marks (markWaitedOnTurn, src/framework.ts:16191–16194). So by the rule above, its start is private when that turn is private then, or when the request is a private wake itself. A quiesce that parks the requests beside a continuation marks them the same way.
  • Storing private input for its own agent, anywhere but a tool boundary, marks the agent (notePrivateInput). Its next start that can publish is private. The mark is spent once that start's stream begins, unless private input was noted while the request it sends compiled, so a silent heartbeat start, or a start that parks or ends before its stream, leaves it to the next.
  • A retake's start is private exactly when the agent's stored history on its current branch holds private input by the marks that mean the same in any run (isMarkedPrivateInput): a surface's stamp, or a private turn's work's mark (dendrite.privateTurn). A script's report, whose mark is a turn id, doesn't count. It reads the stored rows the agent's own view keeps, newest first in 64-row windows without their blobs, and stops at the first marked row (retakeIsPrivate, :16196, through rowsNewestFirst, :1700). So input the context now shows only in a summary still counts, and input that was removed or shed, is on another branch, or sits in a row the agent's view leaves out, such as another resident's stored context injection, doesn't. Nothing is kept about the retake between turns.
  • TurnRouting.nonChannelOrigin is set when a private start opens the turn. makeTurnPrivate sets it when a start that continues the turn takes in a private wake or stored private input, when a boundary injects private input, or when private input reaches a start, opening or continuing its turn, while it gathers its context or compiles; one that it reaches while it compiles stays as it was if it's a silent heartbeat start, or if its agent was replaced or its framework began to stop meanwhile. Once set, it stays set for the turn. A private turn's start routes to no channel and is addressed in none, whichever wakes joined it. Its telemetry still says who woke it.
  • While a turn is private, no envelope sets its sticky destination (pinTurn). That includes an envelope that a send held as a draft, and one in a round that was queued before the turn became private and delivered after it on the speech chain.
  • A private turn's plain speech is kept private in every mode. In locus and hybrid modes its receipt counts it (privateSuppressed) and says it went to no channel. Without that it would reach routing with no locus and be reported as a failed send. In explicit mode it isn't bounced into a draft to resend: a notice says it was kept private and how to publish (keepPrivateTurnProse). Nor is an envelope that bounces in a private turn, in hybrid and explicit mode, because it names no destination, names a channel that doesn't resolve, or its delivery fails, words it adds to an {{unsent}} resend included: the bounce notice says the words were kept private, or, when the send threw, that they may have gone out. Only a bare {{unsent}} resend keeps the draft it sent. Where an earlier draft is held as the latest bounce, either notice says {{unsent}} still names that draft, not these words. Words a send holds back are held as drafts, as in any turn. In locus and hybrid mode a send holds back speech going where it may have posted, or, when the framework can't read where it posted, speech going anywhere (fix(prose): an explicit send holds plain speech only where it may have posted #824). A private turn's unprefixed speech, and an envelope of its that names no destination or names a channel that doesn't resolve, have no destination, so no send holds them back (sendHoldAt's privateTurn).
  • A private turn's channel_publish that names no channel, by a channelId that is missing, empty or not a string, is refused at the model's own dispatch, judged by the turn the call was made in as it stands, a conversation fork's included, and its round doesn't count the refused call as a send. The refusal answers as the dispatcher's other refusals do, so its result carries refused (ToolResult.refused), and the tool-result guard's notice for a withheld one says the tool didn't run. A script's channel_publish that names no channel is refused at the same dispatch when the turn that started the script is private as it stands at the call (workTurnOf), whether it calls the tool by name or through toolsets--act, and the round that watches the script counts it as no send. A host's executeToolCall of it that carries a private turn's id (callerTurnId) is refused too, with a failed result, whether or not it names an agent, as main holds a host's call carrying an open turn's id to that turn's rule; one carrying no turn id is judged as before. One function words each refusal (privatePublishRefusal), and points to channel_list only for an agent that may call it (mayCallTool). Otherwise the call goes where 0.30.0 sends it.
  • In locus and hybrid mode, a private turn tells its resident which channels' messages it holds, and that its plain speech reaches none of them, in a [routing] notice: at a start that takes in a channel's wake (a batch's, a gate debounce window's channel messages, or a wake that continues the open turn), at a live tool boundary that injects a message that addressed the agent or follows up where the turn sent, and at the turn's next start for such messages it stored with no live stream to tell, those noted before it turned private included. Each channel is named once in the turn, by this notice or by 0.30.0's notice that someone addressed the agent from another channel, whichever comes first, since both read and write 0.30.0's record of the channels named (TurnRouting.addressedElsewhere, :22666). Under 'delivery', a private turn's tool boundary runs 0.30.0's notice, then this one (:17197–17201). Under 'latest-inbound', this one runs where 0.30.0 re-pins. In explicit and disabled mode no channel's message moves where plain speech goes, in any turn, so no channel is named.
  • A private turn's channel_open opens the channel for incoming traffic and moves neither the locus nor the trigger channel. Nor does its entry into an all-except hold (focus, or tune_out with scope: 'all-except'), which on 0.30.0 gives an open turn with no locus the excepted channel (:38546), and the hold's answer says the turn's plain speech stays private, a conversation fork's included, whose answer adds that from its next turn its speech stays in its home, except on a turn that holds private input. Its .sleep announcement isn't posted: outside explicit mode it has no locus, and in explicit mode, where the announcement goes to the turn's >> target, a private turn has none, even after an envelope of its names a channel. When the model refuses (a refusal stop), a private turn's refusal reacts on no message, and its failure record names no channel.
  • 0.30.0 stores the routing it last told each agent of (rememberAnnouncedLocus). A private turn's notice that its speech now reaches no channel is stored the same way (:22707), so after a restart the agent's next room turn is told its channel again, as it is without a restart.
  • SpeechContext.nonChannelOrigin tells speech handlers a private turn's speech is private, and the built-in Discord module posts none of it.

Where main's features meet this one

In a round that calls the drafts tool (#716), an unprefixed line written while a channel turn has no destination is held as a draft, as on 0.30.0, and a private turn's is kept private, never drafted. A continuation that now starts alone (#762) is the case under E1 (Tests, below). A private turn's refused channel_publish carries refused, so the guard's withheld notice (#810) says the tool didn't run. In locus and hybrid mode, an explicit send now holds back plain speech going where it may have posted, or, when the framework can't read where it posted, speech going anywhere (#824). A private turn's unprefixed speech, and an envelope of its that names no destination or names a channel that doesn't resolve, have no destination, so no send holds them back: they are kept private beside any send, and such an envelope bounces as a private turn's bounce does, with no draft. Where #824 delivers a hybrid segment no send held, the private turn's rule applies, as at every other hybrid delivery, and an envelope a send held sets no target in a private turn. #819 drops a stored message's wake once a request that read the message has been answered, for the kinds its changelog names: channel messages other than a conversation fork's, push events, a module event's one message, background-script wakes and the gate's self-wake notices. A private surface's message isn't one of them, so its wake isn't dropped, as on 0.30.0 (src/framework.ts:17657). For a wake that carries a channel, the agent must also have published into that channel since, unless the message's server tagged it ambient (chat:ambient) or system (chat:system, #830's notices). A private turn publishes into no channel unless it names one, so a room message's wake that waited on a private turn's continuation still starts a turn of its own, which speaks in no channel. An ambient or system message's wake needs no publish, so once a continuation has read and answered the message, its wake is dropped and starts no turn, as "a stored room message tagged ambient, whose wake waits for a private turn's budget restart, starts no turn once the restart has answered, reaches no channel, and isn't named" asserts, and the test whose title names a message tagged system asserts the same. On 0.30.0 such a message gets no reply of its own either. A private turn whose start takes in such a message's wake names the message's channel, as "a gate wake names its window's channel messages…" asserts for an ambient one. #843 carries an explicit send's hold into the starts that continue a turn: the error policy's retries, a provider hold's retry and a context-budget restart. A carried hold goes through the same sendHoldAt as a send's own (src/framework.ts:1851), so a private turn's speech with no destination still takes no hold: after a send whose place the framework can't read, a private turn's budget restart keeps its words private, where a channel turn's are held. #817's refusal of a cut-off round's calls and #830's notice mark are next to lines this change touches, and each is kept as 0.30.0 has it. #846 sends a persistent agent's turn again when its retries run out on a transient failure, after 2, 8 and 32 minutes (src/framework.ts:949), as a copy of the turn's trigger (:6822). Here that copy also carries the exhausted turn's id, as every other request a turn derives does, so its start is judged by that turn: a private turn's re-send speaks in no channel, and a channel turn's goes where #846 sends it. A room message that comes during the wait starts the turn at once, with the re-send merged in (:20121), so for a private turn that start is private and the room message gets no reply in the room. The notice that names the channels a private turn holds messages from now passes a channel id no server registered through #852's suppliedValue (src/framework.ts:22672). Where #858 has skip_reply name the heartbeat's earlier wake, the merge keeps the self-wake's turn. #861 has a heartbeat's turn take no channel and hold its plain speech as a draft. Here a heartbeat's turn that takes in private input, from an operator's private wake in its batch, an operator's stored message its start reads, or one that makes it private at a tool boundary, keeps its words private with no draft, as any private turn does. One that is private from its start, from a private wake or a stored message, gets no notice that its words are held. A heartbeat's turn with no private input holds its words as a draft, as on 0.30.0. #975 stores a suppressed event's rows with a stamp that the residents' view leaves out. It doesn't decide suppression for a surface's event, or for console or API input (src/framework.ts:17616–17617), so a private surface's message is never stamped, and the opening's scan of a store from before this release (:1707) and a retake (:16196) read rows through that view (:16334–16337). Both are read from the code; no test runs a suppress rule beside private input.

Locked prose routing (#969): a private turn isn't a locked turn while it's private. At a turn's start, the locked branch takes only a turn that isn't private (:25122); a private turn starts with no locus, no announcement of the lock and its private notice. A continuing start names a private locked turn's held channels, as a private locus turn's (:25163). The answer of focus, or of tune_out with scope: 'all-except', in a locked agent's private turn says its plain speech stays private and where it goes on a turn that holds none (:38559). The locked silent start is as #969 wrote it (:25115): a silent start isn't the turn that's open, and each reader of its routing asks whether it's private before it reads the lock, or reads only the open turn. makeTurnPrivate clears the lock's hold on a turn that becomes private (:22650), and a private locked turn names its wake's channels as a private locus turn does (:25191). prose_locus in a private turn says the turn's plain speech stays private and where it goes on a turn that holds none, and a move is recorded for those turns and leaves the private turn as it is (:22026). channel_open in a private locked turn gives the private answer (:37937), a closed channel's invitation made during a private turn defers to the latest word on where speech lands (:18706), and the locked primer says "on every turn that holds no private input" (:784).

What servers are told: for a turn the channel registry's privateTurn option calls private (the framework's callback, :33445), incomingFor returns no message before it asks anything else (src/mcpl/channel-registry.ts:2545), and buildChannelContext takes no outgoing channel, whatever the home or the turn's channel (:2588 there), so a private turn's context/beforeInference channels name no default outgoing channel, a conversation fork's home included, and no incoming message, in every mode. ChannelRegistry is exported, so privateTurn is listed as an addition, and activeChannelResolver's doc now says "Consulted AFTER homeChannelResolver (a fork's home always wins, except in a private turn's channel context, which names neither: privateTurn), BEFORE defaultPublishChannel" (src/mcpl/channel-registry.ts:1097–1099).

Tune-out readers (#959): tune_out and focus are offered only with a reader, and create refuses tuneOut.enabled. Its release of a hold when no reader is configured, and when a persistent reader stops, wakes the resident with no caller's turn id, as any wake that isn't a caller's, and this change's private answers for tune_out and focus apply where those tools are offered. The two lines that pass a caller's turn id on to the start that reads it are in makeTuneOutCoordinator (:37645).

What callers notice, against 0.30.0

main has since released 0.31.0 and 0.32.0, and the paragraph above says where their changes meet this one. These compare with 0.30.0.

  • A turn opened from a private surface publishes none of its plain speech, and nor does a turn after it takes in private input. On 0.30.0 the first takes an open focus's channel, or under 'latest-inbound' the most recent inbound channel, and otherwise publishes nothing either (Problem, above).
  • A channel message that shares a start, a debounce window or an open turn with private input gets no inferred reply in its channel. Where main now starts the turn's continuation alone (fix(turns): handle a stream cancel() that throws; start a continuation without the wakes batched with it #762), the message's wake starts a private turn of its own after it, unless it is dropped once the turn has read and answered the message, as an ambient or system message's is (fix(scheduler): drop a stored message's wake once a request has read and answered the message #819). A channel message whose start is the first to read a private message stored before it gets no inferred reply in its channel either. The private message it reads may be one that woke nothing. In locus and hybrid mode, a [routing] notice names the channels whose messages the private turn holds. The changelog files this as breaking, in non-channel-origin-locus.breaking.md.
  • A turn that becomes private gets a [routing] notice saying so, whatever destination it had, and loses its sticky destination. A silent heartbeat tick, which has no speech to place, gets no notice.
  • In explicit mode, a private turn's unprefixed speech is kept private, with a notice that says so and how to publish. On 0.30.0 it posts to the turn's >> destination if it has one, and otherwise bounces into a draft with an offer to resend it, as 0.30.0's own test/explicit-prose-routing.test.ts shows for a trigger that names no channel. In hybrid and explicit mode, an envelope that bounces in a private turn holds no draft and offers no resend, where 0.30.0 holds it as a draft with that offer. Nor does a round that calls the drafts tool hold a private turn's unprefixed line, where 0.30.0 holds a line with no destination as a draft.
  • A script's channel_publish that names no channel is refused when the turn that started the script is private, whether the script calls it by name or through toolsets--act, and so is a host's executeToolCall, or a module's ctx.callTool, of it that carries a private turn's id (callerTurnId), or one of the two kinds of turn id the framework can't place: one given out in this process before the framework was created, and one no newer than a private turn it has dropped from its record of the latest 4,096 (isPrivateTurn). A script's is refused at dispatch, as the model's is: an agent's denyToolsAtDispatch answers first, and the refusal emits dispatch's tool:failed trace. For the refusal, a channelId that isn't a non-empty string names no channel. 0.30.0's changelog sends a call with no channelId to its turn's channel where it has one, and otherwise to the fallback: an open focus's channel, or under 'latest-inbound' the most recent inbound one.
  • channel_publish's description, and channelId's, say a turn that has taken in private input, and a script such a turn started, must name its channel, as a subagent must. That's one sentence in the first and one clause in the second, and it changes the tool list of every agent offered channel_publish once. The focus and tune_out descriptions each gain the same exception to where plain speech lands while the hold is open, which changes once more the tool list of every resident offered them.
  • An operator's nudge or unstick, or the API's inference.request, is private while the agent's stored history (for an ordinary resident, the resident slot it shares with other ordinary residents) holds a private surface's message or a result or mail from a private turn's work, even right after a channel turn, and while a note that private input waits stands for the agent. On a host with a private surface beside channels, an unstick asked in a room then posts its outcome notice there, and the answer reaches no channel. On a host with no private surface, a retake speaks as on 0.30.0, unless the agent's stored history holds a row an earlier release stamped as a surface's (the last item below). Since 0.30.0 gives a turn with no channel a fallback only while a focus holds or under 'latest-inbound', those are where a retake's answer differs from 0.30.0's.
  • A skip_reply self-wake, a sleep's wake or a tune-out cancel's turn for the resident, a focus's end included, armed in a turn that is private by the time it comes, wakes a private turn. On 0.30.0 such a turn's plain speech takes the fallback where there is one: an open focus's channel, or under 'latest-inbound' the most recent inbound channel.
  • A private turn that enters an all-except hold (focus, or tune_out with scope: 'all-except') keeps no channel, and the hold's answer says its plain speech stays private. On 0.30.0 an open turn with no channel takes the excepted channel. The answer's other sentences end with the same exception, so its wording changes for every turn.
  • What an agent derived or spawned during a private turn, or by such an agent, sends back, what a run handed mail from such work sends, and mail an agent sends while its own turn is private, are private input, and the turn that reads them is private. So an agent that started such work, or handed its words on, during a private turn answers what comes back in no channel, unless it names one. On 0.30.0 these are ordinary messages.
  • A dendrite.privateTurn mark in a message a module stores is dropped, as a callerTurnId key is. 0.30.0 already drops a server's dendrite.
  • A message whose event's metadata.channelId is only whitespace is stamped as a surface's: inboundSource.kind is 'surface', where 0.30.0 stamps no source for it. The stamp's one-line check changes from if (typeof metadata?.channelId === 'string' && metadata.channelId) return undefined; to if (!isNonChannelSurfaceEvent(event)) return undefined;, which counts a whitespace-only id as naming no channel. The stamp also skips the framework's own tool-list notice, since its check doesn't count the notice as a private surface's (src/framework.ts:1966), so a module that stores every external-message keeps that notice with no inboundSource, where 0.30.0 stamps it as a surface's, with a name made of mcpl: followed by the id of the server whose tools changed, such as mcpl:tools-server. A stamped notice would make a later turn that reads it private, and the test "a turn woken by the framework's own MCPL tool-list notice speaks as on main" fails when the stamp goes back to 0.30.0's.
  • The [routing] notice for speech with no channel says it stays on the surface the agent was addressed from, if any.
  • A heartbeat's turn that takes in private input keeps its plain speech private, with no draft, and one private from its start gets no notice that its words are held. On 0.30.0, feat(prose)!: a heartbeat turn holds its plain speech as drafts #861 holds a heartbeat turn's plain speech as a draft, as its test "locus mode: a heartbeat turn's mid-turn and closing prose are both held, and the mid-turn notice says why" asserts.
  • An MCPL server's context/beforeInference channels for a private turn name no default outgoing channel, a conversation fork's home included, and no incoming message. By reading 0.30.0's incomingFor and buildChannelContext (src/mcpl/channel-registry.ts:2523–2525 and :2563 there), with what its framework answers for a turn's channel (src/framework.ts:31801–31803 there), a turn opened from a private surface there names the fallback channel where there is one, or a fork's home, and the latest inbound message as the one it answers. ChannelRegistry's options gain an optional privateTurn, which the framework sets for the registry it makes; left out, the context is as before.
  • A turn that becomes private part-way stops the framework's typing indicator at that boundary (the built-in Discord module's autoTyping loop runs to the turn's end, as on 0.30.0), and from there its inference:tokens and inference:content_block traces name no channel, in every mode. On 0.30.0 such a turn keeps its channel, in its traces too. Its inference:started event, sent as the stream started, still names the channel, so a trace tap that tags a whole activation by that event or by its first chunk, as connectome-host's TTS relay does at 68548adf, voices the rest of the turn there until it follows each chunk's channelId. The breaking fragment says so.
  • The framework keeps one value per resident in its own framework/state record, privateInputWaiting, and each report in its deferred-write queue's record carries callerTurnPrivate. The framework alone writes and reads them. Each opening of a resident whose note doesn't say input waits reads its rows back to its last row of its own with text, 64 rows a window without their blobs. A start that private input reaches while it compiles may compile twice.
  • SpeechContext gains an optional nonChannelOrigin, set on a private turn's speech. A host whose own speech handler publishes gets that speech and posts none of it only if the handler checks the flag; the built-in Discord module checks it.
  • A bridge that sends a channel's messages as external-message events with no metadata.channelId gets turns that are private and reply in no channel, since such a message is a private surface's. On 0.30.0 they take the fallback where there is one.
  • Across the upgrade, work in flight from an earlier release carries no private mark: a subagent's record, held mail, or a script's report deferred over the update. A row an earlier release stored with a surface stamp counts as a private surface's message, so the agent's retakes are private while it's in its stored history, and so is its first start after the upgrade when no row of its own with text follows it. 0.30.0 to 0.32.0 stamp every external-message or api:message event whose metadata names no channel (surfaceSourceFor, src/framework.ts:18423–18432 at v0.32.0, the same function in 0.30.0 and 0.31.0), the framework's own MCPL tool-list notice among them when a module stored it. That first opening writes what its scan finds as the stored note, which later openings read.

What this leaves as it is

  • Content already read (What it guarantees, above), for every turn but an operator's retake, and stored private input the in-memory note doesn't reach: one a new conversation fork copies from its template, a context branch other than the one the message is on, and a store that throws after writing, direct or deferred. Forks, resumed jobs and the subconscious keep no stored note, and reopen their contexts without the scan. A host's undo, redo or branch move takes the stored note with the rows, and the next start reads it, so a move back to unread input makes that start private. A move to where the input was answered leaves an input still noted in memory, which costs one extra private start.
  • The note's writer clears it on any reply row of the resident's own, a tool-call-only one included, while the opening's scan counts only a row with text. So after a turn answers private input with tool calls only, and no own row with text follows, an opening's first start is private, failing closed: a /puppet pair and a tool-only answer look alike in the store. Where the note says nothing waits, the scan takes a row stored from a request that began before another resident's private message landed as an answer. A start that reads input and stores no row of its own leaves the stored note set, so the starts after it are private until one replies. A host's removal of a resident's reply without a branch move, such as removeMessage or a Discord suppression a crash interrupted that create finishes, leaves the note cleared in a running framework, and the next opening's first start private. A rewrite of the deferred-write queue that fails when a report's turn turns private leaves that report restored public after a crash; the queue's own alert reports the failed write.
  • A start that private input reaches while it gathers its context or runs its servers' beforeInference hooks runs those hooks, or has run them, with the turn not yet private, so they may be told the channel context the turn had before it became private. A caller's stop made while a start compiles can't be seen without taking it, so a start that private input reached then compiles again before its stop check drops it.
  • State read once before an await, while a child is made, isn't read again after it. A start parked behind an auxiliary call resumes on the caller turns' privacy as its merge read it, and a derivation takes its parent's privacy before it awaits the child's context; reading these states again after each await is outside this pull request.
  • A gate's debounce line that quotes a module's event the module stored nowhere else carries no private mark, so a retake after its private turn doesn't count it. It takes a module that asks for inference on an event without storing its message.
  • A provider hold that keeps one merged start for an equal one joins their batches, each request keeping its own marks, so marks one start's requests took from an earlier plain request don't reach the other start's requests; carrying each start's marks onto the other's requests would give a request another's own. It takes two equal merged starts in one hold, an earlier plain request deduped into one, and a later withdrawal.
  • A retake doesn't count a script's report. After a turn that was private only because its start read an earlier run's report, a script that turn started reports with nothing a retake counts, so a nudge after that report takes the fallback where there is one.
  • A channel-bound surface message (one whose metadata.channelId names a channel) routes as 0.30.0 routes it.
  • A turn whose id is no newer than one the record has dropped, after 4,096 higher private turns, counts as private for any request that continues its work, its own budget restart and re-inference included, and for a host's executeToolCall that carries its id, though it may not have been. Such a channel turn turns private at its next restart.
  • A module's inference-request and the messages it stores itself carry no turn, as on 0.30.0 (A module's inference-request can't carry a turn's privacy, so an async subagent's report on a private turn is published to a channel #789), so a module's own report on work a private turn started wakes, and is read, as any module's does. A result or agent mail sent through Dendrite carries the mark above. At connectome-host's 5d60376e, the subagent module tells a live primary of a failed run that way, with a message and a wake of its own (deliverOutcome, src/modules/subagent-module.ts:2531 there), and a retry it creates once the private turn has ended is marked only if its spec carries the first attempt's privateTurn, which AgentSpec.privateTurn's doc says a host may set.
  • A tune-out release that a crash deferred, which the next boot restores ('tune-out released (restored)', :10150), opens its turn as on 0.30.0. That turn brings in the channel traffic the hold kept, not an operator's words, and failing closed would make a room's own releases private.
  • A reader fork's own speech and a derived agent's own plain speech are as 0.30.0 has them. At 630e2a3a07, readerMayCall lets a reader fork call speak_in_channel only where allowChannelSpeech allows, and otherwise only the subconscious's own tools (src/framework.ts:27064–27068 there). deriveAgent makes its agent through createEphemeralAgent (:10849 there), which marks the agent's name a run (:10656 there), and a run takes no global fallback (:31809 there; src/mcpl/channel-registry.ts:5779–5781 there).
  • A retake reads the agent's whole stored history on its branch, including input its context now shows only in a summary, so on a host with a private surface it's private while any private input is stored there. Input that was hidden, shed or removed, or is on another branch, no longer counts. A gate policy that holds api:inference-request, which only an explicit host policy does since the default is always, lets it through later as no retake, so after a private turn its turn takes the fallback channel where there is one, as on 0.30.0; the breaking fragment says a host with a private surface needs to keep its gate from holding it. For an ordinary resident, the stored history is the resident slot it shares with the other ordinary residents, so one resident's operator message holds every ordinary resident's retake private. A retake doesn't read a script wake's turn id. Turn ids come from a counter that starts at 0 in each process (src/framework.ts:1674), and the record of private turns starts empty, with a floor at the counter's value when the framework is made, at or below which every id counts as private (:4287–4288). So a stored wake's id read by a later run would name another run's turn, and one read by a second framework made in the same process on the same store would count as private whatever turn it named. connectome-host makes such a second framework when switchSession returns to a session a framework in the process opened before: it stops the framework and makes another on the session's store path (src/index.ts:1169–1178 at 68548adf there). A private turn's wake keeps a retake private only through that turn's own private input while it's in the history. A write deferred and persisted by an earlier release that carries a server's or module's own numeric callerTurnId reads as private input once, at the start that takes it in, since the restore sets any numeric callerTurnId to 0. That fails closed. A search of the repositories we have, connectome-host, context-manager and discord-mcpl among them, finds the key only in this one, where main uses it as a tool call's field. A rewind's retry is queued as a request that continues the refused turn (derivedTurnRequest, src/framework.ts:33292 for an inference failure and :26990 for a refusal), so its start is judged by that turn, not by what the shed removed. Since main's fix(refusal): a rewind sheds the newest turn the refused request carried #835, a refusal rewind's repeated refusals shed older messages too, the agent's own turns included. No test runs a shed that reaches into an earlier private turn.
  • Typing: a host that uses the built-in Discord module may show typing in its last channel during a private turn: its autoTyping loop starts on inference:started and stops at the turn's end. A turn made private part-way stops the framework's indicator at that boundary (C1), but its inference:started event, sent as the stream started, named the channel, so a tap that voices a whole activation by that event needs the breaking fragment's change, following each chunk's channelId.
  • A module's inference-request can't carry a turn's privacy, so an async subagent's report on a private turn is published to a channel #789, a module's request that can't say whose turn's work it continues, stays open: this change gives a module's request and message no turn, as 0.30.0 gives them none.

Out of scope: Three ways a turn can lose its continuation are main's own, and this branch leaves them as they are:

  • cancelling a tool wait that has no stream drops its continuation;
  • a start that throws drops other agents' queued requests;
  • a storage failure while storing the last tool round leaves the turn open.

resolveLocus's global fallback can still pick a channel a conversation fork owns, as it can for any ambient wake.

Each part, and what it costs

What it's for. A resident's plain reply to a private surface must not be published to a channel the resident didn't choose. In the incident, a fresh WebUI turn took resolveLocus's fallback, the most recent inbound channel. On 0.30.0 that fallback is an open focus's channel, or under 'latest-inbound' the most recent inbound channel, and a channel turn that takes in private input keeps its channel.

The least that meets it, and what each part costs:

  • The classifier (isNonChannelSurfaceEvent), about ten lines. A surface event is a channel's when its metadata.channelId names one, and private otherwise. A whitespace-only id names none. So a surface named tomorrow fails closed, where a list of private sources would fail open the day a new surface ships. The cost is that a bridge that forgets channelId goes mute, and its receipt says why. The framework's own tool-list notice, the one external-message it builds itself, is kept out by a set only the framework writes, about five lines: without it, a host that stores every external-message would make a resident's next turn private whenever an MCPL server's tools changed. surfaceSourceFor's stamp now reads the same rule, one line, so the classifier and the stamp can't disagree.
  • The request's mark (InferenceRequest.nonChannelOrigin), carried by the direct path and the gate's wake: a field on two types and their readers. In the gate, a debounce window that holds a private message wakes one private turn and names no route. A window that holds only private messages is timed from the newest of them. That's about ten lines in wakeProvenance, and without them a window's channel message would route the private turn.
  • The turn's mark (TurnRouting.nonChannelOrigin), frozen as fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn #312 freezes the locus, so every start of the turn keeps it, with no locus while it's set. One field.
  • Private wins at every start: one some() over the batch. It replaces the split, about 170 lines of holds and requeues, which still let the room's turn run with the operator's words in its request.
  • Stored private input (notePrivateInput): a set, one call where a stored message's notes are taken, and one where a start's flush takes them. A tool boundary that ends its turn, or a silent turn's, sets it too, for the private input it stores, since neither turn answers it. A start reads the mark, and it's spent once a start that can publish has begun its stream, unless private input was noted while the request it sends compiled. A silent start that reads it is private as well, so its channel_publish names its channel. Without it, a start woken by a room message, whose context holds an operator's message stored before it, would answer in the room. That happens when the operator's message was stored while the agent was idle and its wake waits in a gate window.
  • makeTurnPrivate, about twenty lines, and its notice, about twenty more (storePrivateTurnNotice). It runs at a start that continues the turn, at a tool boundary that injects private input (a few lines that read the existing stamp), and just before a start's compile and at its second compile. It removes the locus, the trigger channel (which the registry reads as a turn's channel when it has no locus, so a private opening start sets none either) and the sticky destination, since the agent's next words may answer the operator, and, unless the turn is a silent heartbeat tick, it tells the agent once, whatever destination the turn had. An explicit-mode turn with none would otherwise meet the private input with nothing saying its words now stay off channels. A start that the mark alone makes private, though a channel's wake opened it, is told why too, in every mode, so its resident doesn't take its reply to be the room's.
  • No pin while private (pinTurn): a three-line helper at the three places a pin is set. Without it, a private turn's own >>>, or a round queued before the private input and delivered after it, would set a destination the turn's later speech follows: a resident who believed the notice would answer the operator in that channel. An envelope runs to the next envelope line, so a message of any length still goes out whole.
  • Derived requests (derivedTurnRequest, and the budget restart's and the re-inference's turns): one helper and two spreads, so what a private turn causes stays private. The auto-continuation main added takes the helper too: without it, a private turn's auto-continuation opened a public turn, which took the global fallback. At a bounce, a ! continuation and the rewind and failure retries, it changes what happens only where a fallback exists, since 0.30.0's changelog gives a turn with no locus none under 'delivery' while no focus holds. Its tests there run on the stand-in registries.
  • A retake's turn (retakeRequest, its RETAKE mark, which carryWakes keeps through a merge, on an operator's nudge and unstick and on the API's inference.request; and retakeIsPrivate): about seventeen lines of code, and nothing stored. A retake adds no input. It compiles the agent's context as it stands and answers it, so whether its answer can carry private input depends only on that context. So its start, in beginAgentTurn before the start records its checkpoint, is private exactly when the agent's stored history on its branch holds private input by the marks the store already writes that mean the same in any run (isMarkedPrivateInput): a private surface's inboundSource and dendrite.privateTurn, not a script wake's turn id. It reads the stored rows the agent's view keeps, newest first in 64-row windows without their blobs, and stops at the first marked row. Without it, /nudge after a private exchange posted the retaken answer to the fallback channel. The other way, a copy of one fact about the context, which turn it ended on, set when a turn opens and cleared at each move the framework itself makes, goes wrong wherever anything else moves the context: connectome-host's own history commands, a module's removeMessages, a host's cm.removeMessage(s). Where the copy is missing it fails closed, which holds a channel-only host's unstick or nudge silent after a restart, a hide or a rewind's shed. Keying the copy by branch would follow branch moves but not removals on the same branch, so its clears and its unknown case would stay. We ran the copy in place of ours at an earlier version of this branch, with a test written for the run: after a private exchange and a channel turn, a host's removal of the channel turn on the same branch, then a nudge, posted the retaken answer to the fallback channel. With ours at that version, the same test passed, and the nudge's answer went to no channel. No test at this head runs that case. Reading the context removes the copy, its clears and its unknown case together. Its cost is that on a host with a private surface beside channels, a retake is held private while private input stays in the history, even right after a channel turn. A rule that reads only the context's newest turn would answer that channel turn in its channel, but it would bring back the question of which exchange a silent tick, several undos, or an undo then a tick leave the retake answering, which is where the earlier holes came from. We timed it at 0dadcb1c, when it read getAllMessages(), on Node 24.22.1 on Linux with chronicle 0.4.0, membrane 0.8.0, context-manager 0.23.1 and mcpl-core 0.4.0 installed, with a channel-only history, where nothing matches and every message is read. Each figure is the first read after a new message is stored, then a read with nothing new: at 1,000 messages, a median of 0.24 ms (3.2 ms at most), then 0.04 ms; at 10,000, 3.4 ms (15.4 ms at most), then 0.48 ms; at 50,000, 3.8 ms (16.7 ms at most), then 0.88 ms. A history that holds private input stops at the first match: with the private message first of 50,000, the read with nothing new printed a median of 0.00 ms. The one-minute load average was 10.3 when the run started and 19.1 when it ended, 729 s later. We haven't timed the windowed read. Like the derived requests at a bounce, it changes what a retake does only where a fallback exists.
  • The note that private input waits, kept in the store (privateInputWaiting in framework/state; recordPrivateInputAhead at the three writes that note private input; storeTurnReply at the four places a turn stores its reply; one line at each start; the read in createAgent; and holdsUnansweredPrivateInput for an opening whose note doesn't say input waits): about fifty lines of code, and one boolean per resident stored. Without a note, a restart after an operator's message was stored and before any start read it lost the note with the message's wake, and the next start, opened by a channel's wake, read the message in a turn that spoke in the channel. Rebuilding the note from the rows at each opening instead reads a /puppet pair, or a reply stored after the input, as its answer, and any report's turn id as private, so a channel turn's report would cost the room its reply after each update. Keeping the note itself makes it one fact the framework writes, set before the row it's about and cleared after the reply, so a crash leaves it no less than the rows it kept, and a branch move takes it with them. It costs one framework/state read per start, per private input row and per reply row, and a write when the note changes. The scan stays for an opening whose note doesn't say input waits, since a release that keeps no note can leave it stale; it reads rows back to the resident's last row of its own with text, in 64-row windows without their blobs, so a resident that has never answered with text reads its whole history at each opening.
  • A start that private input reaches while it assembles (one check before the compile; the count privateInputNoted, one line in notePrivateInput and one at each of three disposals; the second compile; the hand-off's comparison): about twenty lines of code. Without them, another resident's start that was gathering its context or compiling read the message on a public route and spoke in the room. A turn made private as its stream began would never have been told, and could still post the message's words with an envelope or a send tool naming a channel, so the start compiles again instead. That costs one more compile in a rare case, and nothing is sent twice.
  • The shared slot's readers (one call in noteForOtherResidents, gated by the reader's view): without it, another ordinary resident's start that was the first to read an operator's message in their shared slot spoke in its channel with the message in its context.
  • The record of private turns (notePrivateTurn, isPrivateTurn): a set of the 4,096 highest private ids and a floor, written at the two places a turn becomes private, about twenty lines of code. A script's report, deferred while the host is quiesced for an update and stored by the next run, can't keep its turn id, which the next run may give out again. The queue keeps the report's privacy beside it, as the queue is written and again when its turn turns private, and the restore takes a public report's id off and gives any other 0: seventeen lines of code. Without that flag every such report came back private, so a host with scripts and no private surface lost a channel's reply after each update that caught a script running. A private report's landing makes the start that takes it in private, and a retake doesn't read the id, so nothing after counts it. It lets work that comes back after its turn has ended go by that turn as it stands then. A flag taken when the work started would read "channel" for a turn that took in private input afterwards, and the turn the work wakes reads that input. Failing closed past the cap costs a rare late wake its channel.
  • Work a turn started, judged by it at the start that reads it (CALLER_TURNS with continuesTurns, callerTurnsOf and carryWakes; workTurnOf, requestForTurn, WakeProvenance.callerTurnIds, and the turn id armSelfWake, setSleep and a tune-out cancel take): about forty lines across the framework and the gate, with one judgment in turnIsPrivate. Without them a private turn's script, self-wake, sleep or tune-out cancel woke a public turn, which took the global fallback. A request judged when it was queued would miss a turn that turned private afterwards. The end of a focus that a private turn asked for wakes the resident by that turn too, through one more parameter of cancelAllExcept. These requests also change what happens only where a fallback exists.
  • A private turn's Dendrite work (AgentRecord.privateTurn and HeldMail.privateTurn, the mark where createEphemeralAgent makes an agent and where mail is handed to a run, sentFromPrivateWork, and the stamp on a result, a message and a restart's interrupted notice). 8c5e233696, which adds them with the module guard below, adds 63 lines to src and removes 7, 25 of the added lines in src/dendrite/. On 0.30.0 what such work sends back is ordinary input, so the turn that reads it answers where it would answer anything else. Their tests run on the stand-in registry (Not verified). The mark at createEphemeralAgent asks sentFromPrivateWork of the parent or spawner, so work a private turn's work starts is marked too: without it, a marked worker whose own turn wasn't private started an unmarked one, whose result a resident read as ordinary input.
  • Only the framework writes the work mark (withoutPrivateWorkMarks, which replaces withoutCallerTurn): nine more lines in the function that already dropped a module's callerTurnId. Without them, a module's stored message could carry the mark and make a channel turn that reads it private, which "a module's message that carries a private turn's work mark leaves a channel turn speaking in its channel" fails on.
  • A focus a private turn enters (one condition, !open.nonChannelOrigin, :38546, and a branch of the hold's answer). Without the condition, the private turn takes the focus's channel as its locus. Its words still stay private, but the typing indicator, which follows the locus (followTurnLocus, :26018–26031), shows in that channel. The answer's sentences each gain the private exception, and so do the focus and tune_out descriptions, which changes the tool list of every resident offered them once. A conversation fork's private turn gets the same answer, with its home named for its next turn except on a turn that holds private input: one more branch, without which the fork was told its private speech stays in its home.
  • A turn made private part-way stops the framework's typing and traces no channel (four lines in makeTurnPrivate, one condition in tracedChannelId). Without them its typing stayed in the room the turn started in, and its later chunks named that room, so connectome-host's TTS relay voiced the private answer there.
  • The announced baseline (one argument, :22707). Without it, the room's next turn after a restart isn't told its channel, since 0.30.0's stored record still names the channel the agent was told of before its turn turned private.
  • What work brings back, as private input (isPrivateInput, CALLER_TURN_KEY, withoutPrivateWorkMarks): about fifteen lines. Without it, a public turn running when a private turn's script wake was stored read it at its next boundary, stayed public, and could publish what it said. Only the framework writes the key: a server's copy is dropped (HOST_METADATA_KEYS), and so is a module's where its writes enter, so neither can mark its messages as a private turn's work and keep the resident's next turn from replying in its channel.
  • The notice of a private turn's channel messages (noticeChannelMessages), about seventy lines of code with its sites: the channels a start gathers from its batch (wakeChannelsOf) and a gate window lists, kept when wakes merge in a quiesce or a provider hold (carryWakes) and given to each agent by its route's rule, a live boundary's would-be re-pins, and a private turn's arrivals, which it now notes and names instead of re-pinning; and 0.30.0's record of the channels named in the turn (addressedElsewhere), which replaced this branch's own, so each channel is named once. Under 'delivery', five lines at a tool boundary (:17197–17201) name an unaddressed follow-up where the private turn sent, which 0.30.0's notice, for messages that addressed the agent, doesn't. Without it, a room's message absorbed into a private turn was dropped silently: the resident answered both in plain speech, and only the room's half was kept. The wakes carry the channels. A per-agent set of the channels whose messages were stored since the last start could have carried them instead. Such a set would be smaller, but it would also hold messages whose wakes the gate dropped or still holds in a debounce window, and naming those would steer a send at a message its own turn later answers too. The wakes name exactly what the private start took. That comparison comes from reading the code, and no run compared the two.
  • A private start routes to no channel, one condition: a channel's wake that joined a private turn otherwise points explicit mode's typing and speech handlers at its channel. A turn that becomes private mid-stream shows its speech handlers no channel either, one more.
  • Merges keep the mark: one token in the quiesce key and one clause in a provider hold's dedup, and a hold's dedup that meets a merged start gives each request of its batch the plain request's marks, its private wake mark included (carryTurnMarks, three lines, which carryWakes now calls for the same two marks, and seven lines in holdRequest), without which a retake or a private turn's continuation that a hold kept as one with a merged start lost its mark once the batch was expanded or put back. Guessing at the expansion which marks a dedup added would lose a mark one request already held, and give a withdrawn request's marks to the others. Without them, a newer wake could evict a private one with the same reason. A hold could also keep only one of a private and a channel message that one source sent in one millisecond.
  • Receipts (suppressPrivateTurnProse at the three sites where speech would otherwise reach routing with no locus, and hybrid's allowLocus): one counter, one note. In explicit mode, keepPrivateTurnProse is one condition and one notice: a bounce there would hold a draft, and resending it is the step into a channel that the turn's privacy is there to prevent. A private hybrid turn's unprefixed speech is counted as kept, not bounced as having no destination. Words a send holds back are held as drafts, as in any turn, since resending them takes a destination the resident names.
  • The no-channel notice's wording, one sentence. It says the speech stays on the surface the agent was addressed from, if any, where it said the archive. A private turn's agent reads it, and its speech does stay there.
  • The re-pin guards: two conditions, so an addressed channel message can't move a private turn's locus under 'latest-inbound', where 0.30.0 still re-pins.
  • The model's channel_publish without channelId, refused in a private turn: about forty lines with its round's count and the XML fallback's scan, since its default is a channel the resident can't see. It's judged by the turn the call was made in, and the round by its stream, since a later turn can be live by the time a call dispatches. A script's is refused at the same dispatch when its start turn is private (workTurnOf, one condition), since a script runs apart from its turn, whether it calls the tool by name or through toolsets--act; the bridge passes the start turn's privacy to its round's count, one argument, without which a refused call counted as a send for the round watching the script. A host's executeToolCall carrying a private turn's id is refused by four lines in its channel route, without which it posted to the fallback. The refusal counts a channelId as naming a channel only when it's a non-empty string, where publishInputWithTurnLocus keeps any string as given, an empty one included: by truthiness, channelId: 1 passed the refusal and was replaced by the fallback. Its hint names channel_list by mayCallTool, the rule dispatch applies. The script bridge has no copy of the refusal of its own, since dispatch refuses the same calls.
  • channel_publish's description, one clause in it and one in channelId's: without them the description offers a private turn a default it then refuses, which an agent reads as true. The cost is one rewrite of the tool list of every agent offered channel_publish, as 0.27.0's change to channel_close's description made.
  • channel_open and .sleep in a private turn: a branch each, so opening a channel to read it doesn't route the turn's next words there, and a sleep announcement doesn't take a channel nobody named.
  • The refusal guards: two conditions. A refusal stop reacts on the latest inbound message, and records its channel. In a private turn that's a channel's message the turn wasn't answering.
  • SpeechContext.nonChannelOrigin and the Discord module's return: a field and a line. The module is the one built-in output path besides the registry.
  • The tests' rig (surfaceRig, SurfaceModule, SlowModule, robot, storeOperatorMessage): the real scheduler and stream, with a stub registry that records each post and can hold it (holdRoutes, whileRunning). quietSources stores a surface's message without a wake, and unstoredSources wakes for one that nothing stores, so a gate window's private wake can be its only mark. codeExecution turns scripts on, and SlowModule's tool answers when the test releases it, so a script can be left running. storeOperatorMessage stores a message with the stamp the framework writes, where the other tests send the event the framework stamps. explicit-prose-routing's trigger names the channel its stub registry resolves every locus to, so its sticky-target tests run in a channel's turn as they did on main, and its private-turn tests ask for a private trigger. tune-out-e2e's cancel test runs through the MCPL fixture server, with a surface module of its own. held-turn-retry's input module stores a message from QUIET_SOURCE without a wake, so an operator's message can arrive during a hold. D5's next run is a second framework on the same store, in the same process, and the restore runs when it's made. hostUndo moves the context as connectome-host's /undo does, through the context manager alone, and ApiModule sends the API's inference request. Each test pins one rule above, named in its title. The helpers that read back what a test checks: channelNotices, named (the channels each notice names), noticeBeforeReply and stored (the store as text, for a refusal) in the provenance file, with PRIVATE_FROM_HERE and NO_DEFAULT_CHANNEL, the notice and refusal they look for; and publishing and driven in code-execution's, whose harness runs the framework in steps.

Tests

Runs, on Linux (x86_64) with Node 24.22.2 and npm 11.19.1, after npm install, which gave @​animalabs/chronicle 0.5.1, @​animalabs/context-manager 0.24.2, @​animalabs/mcpl-core 0.4.0, @​animalabs/membrane 0.8.0, @​modelcontextprotocol/client and @​modelcontextprotocol/server 2.4.0, discord.js 14.28.1, tsx 4.23.15, typescript 7.0.2 and ws 8.22.1:

  • The whole suite at this head, at a clean checkout of f055cb28d7: tsc --noEmit reports no errors, the build succeeds, and npm test's runner, with the TAP reporter, runs 9,901 tests, of which 9,839 pass and 62 are skipped, with none failing.
  • Red first, at the base: at 074ac60be0 with this head's test/all-except-e2e.test.ts, with the same Node and npm, 14 of the file's 35 tests fail. The two incident tests, "under 'delivery', an operator's message while a focus holds is answered in no channel" and "under 'latest-inbound', an operator's message after a room was answered is answered in no channel", fail with the answer published in work and in noisy. Seven others are the tests that a private turn "tells a server no channel": there the base tells the server the latest inbound message, { channelId: 'work', messageId: 'w1' }, as the one the turn answers, and for a conversation fork's private turn, its home channel.

What each requirement asserts, and the tests that pin it. Each requirement says what someone meets, and its tests assert that: what reaches a channel (every routeSpeech and deliverSpeech call the registry gets, a call with no locus included), what the resident is told, a tool's result, typing, or what modules hear. Tests are named by their titles, mostly in turn-trigger-provenance, and a title cut short is quoted with "…". turn-trigger-provenance and explicit-prose-routing run on stand-in channel registries that give a turn with no channel a fallback channel (test/turn-trigger-provenance.test.ts:554; test/explicit-prose-routing.test.ts:129 and :2322), as a focus hold and 'latest-inbound' do on 0.30.0. A test whose rule changes what happens only where such a fallback exists has a title ending ", even where a fallback channel exists", cut here.

  • A1. A surface's message whose metadata.channelId names no channel is private, a source added later included; one that names a channel routes as on 0.30.0. "treats an unknown future external-message source as private", "a private-surface turn asks the registry for no locus, though it has a channel to give, and keeps its prose private", "a private-surface turn asks the registry for no locus, though it would give one as the resident's own, and keeps its prose private", "an API message that names no channel opens a private turn", and main's tests whose triggers now name their channel.
  • A2. The framework's own notices aren't private input, and a module or server can't make its message count as a private turn's work. "a turn woken by the framework's own MCPL tool-list notice speaks as on main" and "a turn woken by a module's event with the notice's fields is private", "a module's message that carries a private turn's key leaves a channel turn speaking in its channel" and the same test with "work mark" for "key", and ingress-host-keys for a server's copy.
  • B1. A private turn publishes none of the plain speech it writes once private, in every mode, whatever wakes joined it. A round written before it turned private goes where it was written to, and sets no destination for what follows. "a private turn's round speech stays on its surface…", "a private turn's words beside a send to a known place, and after it, are kept private…" (and beside a send whose place the framework can't read), "a private turn's text-only reply counts each segment…", "hybrid keeps a private turn's unprefixed prose on its surface", "a private hybrid turn's speech with no target…", "a round written before the turn turned private, delivered after it…", "a held turn's retry speaks in no channel when an operator's message stored during the hold makes the turn private" (held-turn-retry), the two incident tests in all-except-e2e, "under 'delivery', an operator's message while a focus holds is answered in no channel" and "under 'latest-inbound', an operator's message after a room was answered is answered in no channel", and every test below that asserts nothing was routed.
  • B2. Deliberate sends still reach the channel named, and no envelope gives a private turn a sticky destination. "a deliberate envelope is delivered alike in a private turn and a channel one…", "a private turn's own envelope posts its body where it names…", "hybrid mode: a private turn's envelope a send held sets no target, so its later plain speech stays private", "a private turn's channel_publish that names a channel goes there…", "a script's channel_publish that names its channel goes there, in a private turn too".
  • B3. A private turn's words are counted as kept private, and the resident is told so as a decision, not a failure. They aren't drafted or offered for resend, with one exception: an envelope that names a channel that resolves, beside a send that may have posted there, is held as a draft, as in any turn. "an explicit-mode turn that had (or had no) destination is told it's private…", "a private hybrid turn's envelope that names no destination keeps its words private, with no draft…" (and one that names a channel that does not resolve, or a channel whose delivery fails, in hybrid and in explicit mode), "hybrid mode: a private turn's envelope that names a channel that does not resolve, beside a send to a known place, keeps its words private, with no draft", "hybrid mode: beside a send whose place the framework can't read, a private turn's envelope that names no destination keeps its words private, with no draft" (and one that names a channel that does not resolve), "hybrid mode: beside a send whose place the framework can't read, a private turn's envelope that names a channel is held as a draft, since the send may have posted there", "a private hybrid turn's round that calls the drafts tool holds none of its plain words as a draft" (and in explicit mode), "a private hybrid turn's kept-private notice says which earlier draft {{unsent}} still names" (and in explicit mode), "a private hybrid turn's resend that adds its own words to {{unsent}} holds no new draft when its delivery fails" (and when it throws, and in explicit mode), "a private hybrid turn's bare {{unsent}} resend whose delivery fails keeps the draft it sent, and offers it again" (and in explicit mode), "a private turn's bounce wakes a turn whose plain words stay private…", "a private turn that takes in more private input is told nothing again", "a locus/explicit/disabled-mode room start that a stored operator message makes private is told why…", "a disabled-mode turn is told once…", "a turn whose private notice cannot be stored is private all the same", "a private turn whose draft may already have been posted says nothing is confirmed…", and in routing-baseline-restart, "a channel turn made private is what the agent was last told, after a restart too". No false [discord-send-failed] can be stored: that marker comes only from a failed route, and the routed assertions above see every call the framework makes.
  • B4. A private turn's channel_publish that names no channel (no non-empty string channelId) is refused: the model's by its own turn, as a refusal (refused), a script's by its start turn, whether it calls the tool by name or through toolsets--act, and a host's executeToolCall that carries a private turn's id; and the description says so. "a private turn's channel_publish that names no channel is refused, and says how to name one", a conversation fork's included, "a private turn's channel_publish whose channelId is a number, not a string, is refused as one naming no channel is, and reaches no channel", beside one "naming its channel by id", which posts there, "a private turn's channel_publish refusal names no channel_list to an agent refused it at dispatch", "a script's channel_publish that names no channel is refused in a private turn, and posts nothing, and its round counts it as no send", beside a channel turn's, whose round counts it as a send, "a script's refusal in a private turn points to channel_list only for an agent that may call it, not one refused it at dispatch", "a script a private turn started, left running past that turn, is refused a channel_publish through toolsets--act that names no channel while a channel turn's wait watches it, and that turn's prose still goes out", "a script a channel turn started, left running past that turn, publishes a channel_publish that names no channel while a private turn's wait watches it, judged by its start turn", in publish-default-locus "a host's call carrying an open private turn's id meets the private turn's rule: refused, though a focus holds another channel", the same naming no agent and through a module's ctx.callTool, beside one naming its channel, which posts there, "a script left running past a private turn's end is refused…", "channel_publish's description says…", and the round's accounting: "…counts its channel_publish that names no channel as no send…", "…is no send on the fallback path either…", "XML tool mode: a private turn's refused channel_publish silences nothing…".
  • B5. Nothing else picks a channel for a private turn. "channel_open keeps a private turn private…", "channel_open of an inbound-only channel in a private turn…", "a private turn's sleep announcement is posted nowhere…" and "in explicit mode, a turn that wrote a >> target and then sleeps announces nowhere, private from its start" (and "…private from a tool boundary", beside "…announces there, as a channel turn"), "a private turn's refusal reacts in no channel", "a private turn's refusal rewind retries privately, and its record names no channel…", "a private surface's turn posts no speech and edits its thoughts into no message" (discord-thoughts-own-turn), "a private turn's speech reaches modules marked private", "a turn that turns private at a boundary shows its speech handlers no channel…", and in all-except-e2e, "a private turn that enters a focus keeps no channel, and is told its plain speech stays private", "a conversation fork's private turn that enters a focus is told its plain speech stays private, not that it stays in its home", "a private turn tells a server no channel it speaks in and no message it answers", in locus, hybrid and locked mode under either locus rule, and "a conversation fork's private turn tells a server no channel, its home included, and no message it answers".
  • B6. Under locked prose routing, a private turn isn't locked while it's private: it speaks to no channel, shows none of the framework's typing and is told nothing of the lock as its destination, and the lock stays stored for the turns that hold no private input. In locked-prose-routing, "a private turn isn't locked: its words reach no channel, it shows no typing, no notice names the lock for it, and channel_publish needs a channel", "in a private turn, prose_locus says the turn is private and where a turn that holds none speaks, and a move applies from the next such turn", "a locked turn made private mid-way is no longer locked: prose_locus says it is private, a move waits for a turn that holds none, and nothing calls its words drafts", "channel_open in a private turn moves nothing, and says the turn's plain speech stays private", "a closed-channel invitation made during a private turn defers to the latest word on where speech lands, naming no lock", and "plain speech goes to the lock, not the channel the turn came from, and the switch is primed once", whose primer now says "on every turn that holds no private input"; in turn-trigger-provenance, "a locked agent's private start that takes in a room's message names the room before the reply, and shows typing nowhere" and "a locked agent's private turn whose boundary is over budget names an addressed message it took in, for the restart to read"; and in all-except-e2e, "a locked agent's private turn that enters a focus is told its plain speech stays private, and that a turn that holds none speaks at the lock".
  • C1. A tool boundary that injects private input makes the turn private from there: the framework's typing indicator stops there and shows nowhere after, and its later rounds' inference:tokens and inference:content_block traces name no channel, in every mode. "a room's turn made private at a tool boundary stops its typing there and shows none after, and its private round's token and block traces name no channel", in locus, hybrid, explicit and locked mode, each beside its control with no private message, whose later round's traces name its channel; "a boundary that injects a private surface's message makes the room's turn private from there…", "under 'latest-inbound', an ADDRESSED mid-turn injection moves no private turn's locus, where it moves a channel turn's (above)", and "a room's turn whose boundary injects what a private turn's work brought back is private from there".
  • C2, C3. A start whose batch holds a private wake, a gate window's included, is private, and so is a continuation that takes one in. "a batch that holds a private surface's wake is private…", "a gate window with a private message, which nothing stores, and an addressed room message wakes one private turn…", "a room's wake that continues an open private turn leaves it private…", "a continuation that takes in a private wake makes its channel turn private from there…", and E1's tests of a wake that waited on a private turn's continuation.
  • C4. The first start to read private input stored before it is private. "a private message stored while the agent is idle makes its next start private, and only that one", "a private surface's message a start's flush stores makes that start's turn private…", "a silent start leaves the mark…" (B4 shows the silent start is private: its channel_publish with no channel is refused, where the same start with no private input doesn't refuse it), "a budget restart that reads a stored private message is told so, unless it's silent", "a silent turn's boundary that stores private input…", "a boundary that ends the turn leaves it a channel turn…".
  • D1. What a turn derives speaks where it would. "a ' !' continuation of a private turn speaks in no channel…" (and of a channel turn, as on 0.30.0), "a private (or room's) turn's auto-continuation…", "a private turn's refusal rewind retries privately…", "a retry after inference failures shed history, from a private turn, speaks in no channel…", "a private turn's bounce wakes a turn whose plain words stay private…", and "a late bounce from a private turn, landing while a channel turn is live, …" (and the reverse). For the re-send after a turn's retries run out (feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits #846): "a turn private at a tool boundary by an operator message that wakes nothing whose retries run out is sent again in no channel", the same by an operator message that wakes it, "a turn opened by a private surface whose retries run out is sent again in no channel", and "a turn of a channel whose retries run out is sent again in its channel, as on main".
  • D2. Work a turn started wakes a turn that speaks where that turn would as it stands when the work comes back. Each wake is armed by the model's own call in the turn: "a skip_reply's wake, armed by the model in a private turn, speaks in no channel…", "a sleep's wake, armed by the model in a private turn, …", "a script the model left running in a private turn wakes turns that speak in no channel, by wake_agent() and by its failure", in tune-out-e2e, "under 'latest-inbound', the resident's turn after its own tune_out cancel" "speaks in no channel when a private turn cancelled"; and in all-except-e2e, "under 'latest-inbound', the turn a focus cancel wakes speaks in no channel when a private turn cancelled"; each beside its channel-turn case, which speaks as on 0.30.0; and "a script's wake queued while its turn was a channel's speaks in no channel once that turn turns private first…".
  • D3. What that work brings back is private input while its turn is private. "what a private turn's work brought back, stored while the agent is idle, makes its next start private", and C1's third test.
  • D4. An operator's retake (nudge, unstick, or the API's inference.request) is private exactly when the agent's stored history, as its own view keeps it, holds a private surface's message or a result or mail from a private turn's work as it starts, on the branch the context is on then, however the context came to end there, or when, as any start can, it takes a note that private input waits. A script's report doesn't count for a retake, whatever turn it names. With none, it speaks as on 0.30.0. "a nudge after a channel turn speaks as on main, in the fallback channel", "an unstick after a private turn speaks in no channel…", "an inference request through the API after a private turn speaks in no channel…" (and after a channel turn, as on 0.30.0), "a nudge asked for while a channel turn runs, which then takes in private input, speaks in no channel…", "a nudge after the host's own undo, twice, back to a private question speaks in no channel…" (connectome-host's /undo: cm.branchAt and cm.switchBranch), "a nudge after a channel turn that followed a private exchange speaks in no channel…", "a nudge after the host's own undo, twice, back past a private exchange speaks as on main, in the fallback channel", "an unstick after the operator hides messages, in a context that never held private input, speaks as on main, in the fallback channel", "after a restart, a nudge following a channel turn speaks as on main, in the fallback channel" and "…following a private turn speaks in no channel…", "a nudge after a channel turn and a silent heartbeat tick that took in a stored private message speaks in no channel", "a nudge parked behind an auxiliary call, while the host removes the private exchange before it, speaks as on main, in the fallback channel", "a nudge merged with a channel's wake after a private turn speaks in no channel…"; and for a row the view leaves out, "a private-marked row that only its own resident's view keeps leaves another resident's retake, a nudge after its channel turn, speaking as on main", beside "a private-marked row another resident's turn read makes that resident's retake, a nudge after it, speak in no channel".
  • D5. Work from a private turn that comes back after a restart stays private, at the start that takes it in, and a channel turn's report comes back as a channel turn's; a script's report restored as private keeps the turn id 0 the restore gives it, which a retake doesn't count. "a private turn's script report deferred across an update's restart, naming an earlier run's turn id above the next run's floor, keeps the next run's start out of every channel, and leaves a later turn its channel", and the same with the report's own id as a control; "a channel turn's script report deferred across an update's restart leaves the next run's start speaking in its channel, and a nudge after it as on main"; "a channel turn's script report deferred across an update's restart, where its turn turned private after it was queued, and the host crashed, keeps the next run's start out of every channel", and the same "where its queue entry doesn't say whether its turn was private, as damage or a hand edit can leave one"; and "a private turn's script report deferred across an update's restart, landed by the next run and read by no start before another restart, keeps the third run's first start out of every channel", beside a channel turn's, which "leaves the third run's first start speaking in its channel".
  • D5a. After a restart, private input the resident hasn't answered makes its first start private, whatever part of the store a crash kept, and a /puppet pair, a reply that didn't read it, or a silent heartbeat tick's row after it isn't its answer; a channel turn's report isn't private input; a store from before this release, or a note one such a release left stale, is read from its rows; and a retake reads no run's turn ids. For each of a private surface's message, a private turn's work's mail, and a private surface's message stored under the resident's own name: "… stored before a restart, which woke nothing and no start read, keeps the next run's first start out of every channel"; "a private surface's message a silent heartbeat tick read before a restart keeps the next run's first start out of every channel"; "a private surface's message stored before a restart, with a /puppet pair stored after it, keeps the next run's first start out of every channel"; "private input stored for another resident while a resident's channel turn runs, which that turn doesn't read, keeps the resident's first start after a restart out of every channel"; "a private surface's message stored after the host moves its branch back past an earlier unread one keeps the next run's first start out of every channel"; "a private surface's message a start answered, after the host moves its branch back to before that answer and a silent heartbeat tick runs there, keeps the next run's first start out of every channel", and the same where the host moves it "during a silent heartbeat tick"; "a store from before this release, holding a private surface's message no start read, keeps the next run's first start out of every channel, across a further restart with a /puppet pair stored between"; "a store from before this release, holding a private surface's message no start read with a /puppet pair stored after it, keeps its first opening's first start out of every channel"; "a private surface's message a release that keeps no note stored after this release's note said nothing waits keeps the next run's first start out of every channel"; "wherever a crash cuts the store's log, from before a private surface's message is stored to after the start that read it answered, the next run's first start speaks in no channel while the message is stored unanswered, and in its channel once the answer and its record are", which opens a copy of the store's files as they stood after each record the run appended; "a channel turn's script report stored before a restart, which no start read, leaves the next run's first start speaking in its channel, and a nudge after it as on main"; "a private-marked row that only its own resident's view keeps, stored before a restart, leaves another resident's first start in the next run speaking in its channel"; and "a channel turn's script report the agent answered, in a store a second framework opens in the same process, leaves that framework's nudges as on main".
  • D5b. In a running framework, an ordinary resident whose view keeps a private message stored for another resident of their shared slot gets a private first start to read it. "a private surface's message stored for one resident keeps another resident's next channel turn, which reads it in their shared slot, out of every channel", and "a private-marked row that only its own resident's view keeps leaves another resident's channel turn speaking in its channel". While that start assembles: "a private surface's message stored for one resident while another resident's channel start gathers its context keeps that start out of every channel, and tells it its turn is private"; and, for such a message stored after the start's compile read the store, "makes that start compile again, private, before it sends anything, and leaves the next channel turn in its channel", "compiles that start at most twice, and a message stored while it compiles again keeps the next start out of every channel", "compiles once a silent heartbeat start, and leaves the message for the next start, private with the notice", "compiles once a start whose framework begins to stop during its compile, and stores no notice for it", and "compiles once a start already private before its compile, and keeps the next start out of every channel".
  • D5c. In a running framework, a start after a host moves its branch back to before an answer to private input is private. "a private surface's message a start answered, after the host moves its branch back to before that answer, keeps the next channel turn out of every channel".
  • D6. What an agent derived or spawned during a private turn, or by such an agent, sends back is private input, and so is what a run handed mail from such work sends, and mail an agent sends while its own turn is private. "agent mail sent while the sender's turn is open" "is answered in no channel, when the turn was private"; "an agent derived or spawned while its parent's turn is open" "derived, returns a result its parent answers in no channel, when the turn was private", and the same for one spawned; "spawned outside any turn and handed mail from a private turn, returns a result its parent answers in no channel"; "a worker a private turn spawned, spawning one of its own in a turn of its own, gives it a result its grandparent answers in no channel", and the same deriving one; each beside its room's case, answered "where a channel-less turn's speech goes"; and in dendrite-lifecycle, "a fork a restart interrupted wakes its parent into a private turn, when it was derived while a private turn was open", beside "…into an ordinary turn, when it was derived in another turn".
  • E1. A channel message that meets private input gets no inferred reply in its channel, a message whose wake waited on a private turn's continuation included, and in locus and hybrid mode the private turn names its channel, once, before it replies, unless the message's wake is dropped once the turn has read and answered it, as an ambient or system message's is (fix(scheduler): drop a stored message's wake once a request has read and answered the message #819); explicit and disabled mode name none. "a room's wake that arrives while a private turn runs waits for its budget restart, then starts a turn of its own that speaks in no channel", and the same during a quiesce, beside a channel turn's, which speaks in its channel, as on 0.30.0; "a stored room message whose wake waits for a private turn's budget restart starts a turn of its own that speaks in no channel", beside a channel turn's, whose wake is dropped once the restart has answered in its channel, as on 0.30.0; "a stored room message tagged ambient, whose wake waits for a private turn's budget restart, starts no turn once the restart has answered, reaches no channel, and isn't named", and the same for one tagged system; in turn-continuation, "starts a wake held ahead of a private turn's guard retry as a private turn of its own"; "a private start that takes in a room's message names the room before the reply…", "a room's turn made private by a stored operator message names the room it was woken for", "a room's wake that continues an open private turn names the room", "a channel's message is named once in a private turn…", "a private turn's boundary that injects a room's addressed message names the room…", "under 'delivery', a private turn's boundary that injects an unaddressed follow-up from a room the turn has engaged names the room with the injections", "a private (or room's) turn's boundary over budget…", "a gate wake names its window's channel messages…", "an addressed arrival … is named at the next start…", "an arrival removed before the private turn's next start isn't named", "a private start that takes in a wake for a channel no server registered names it on one line, with its notice openings marked", and the explicit and disabled mode starts above. A room message that comes while a turn waits to be sent again (feat(turns): send a turn's trigger again when its retries run out on a transient failure, with delays of 2, 8 and 32 minutes, and alert on an hour of stated waits #846): "a room message that comes while a private turn waits to be sent again starts it at once, and gets no reply in the room", beside "a room message that comes while a channel turn waits to be sent again starts it at once, and is answered in the room, as on main".
  • E2. Merges keep a private wake private. "a quiesce keeps a private wake apart from another with the same reason…", "a quiesce that keeps one of two parked wakes keeps the other's channels on it…", "a provider hold that holds a private wake beside an equal one wakes a turn that speaks in no channel", "a provider hold that takes in a request continuing a private turn, equal to a plain one it holds, keeps that turn on the one it keeps, and its turn speaks in no channel", "a held merged start that takes in an equal request continuing a private turn gives each of its requests that turn at the dedup, and starts private once the hold's release judges its batch again", the same for an equal retake, over a history that holds a private surface's message, and for an equal private wake, "…taken in by an equal plain request the hold kept gives each of its requests the kept request's private turn…" and the same "taken in by an equal private wake the hold kept", beside "…whose request continues a private turn gives that turn to no other request…", "…whose request continuing a private turn is withdrawn as a coalesced wake starts its other request in its channel…" and "…that takes in an equal request with no mark makes no request private, and starts in its channel…", and "a quiesce that keeps the newer of two parked wakes with one reason keeps the older one's private turn on it, so the resumed start speaks in no channel".
  • F1. Channel turns, heartbeats and push events with no private input route as on 0.30.0. main's tests in agent-allowed-tools, code-execution, cut-off-calls, explicit-prose-routing, outgoing-stream, present-while-acting, tool-input-unparseable, tool-result-guard and tool-wrapper-prose-guard-integration, whose triggers now name their channel, and each parameterized test's channel-turn case.

Not verified

  • The non-streaming fallback is tested through its producer. Only a call to Agent.runInference, or to Agent.runInferenceWithInjections, which it calls, runs an agent on that fallback. Apart from runInference's own call, nothing in this framework's src calls either, and neither does connectome-host's main at fab6ab93c6.
  • The built-in Discord module is tested with a stub client.
  • No test sends a private turn's derived, spawned or mailed work into a turn that has its own channel under 'delivery'. Those tests run on turn-trigger-provenance's stand-in registry, where the turn that answers has a fallback, and dendrite-lifecycle's restart test asserts that the start its interrupted notice wakes is private, not where its words go.
  • No run went through connectome-host. Its subagent module at 555b9efd sends a resident's words to a subagent with sendAgentMessage (src/modules/subagent-module.ts:2908), sends a subagent's outcome back with deliverAgentResult or sendAgentMessage (:2532–2539), except a failed run's for a live primary, which it stores and wakes itself (:2529, and What this leaves), and starts a spawn with createEphemeralAgent and spawnedBy (:3205–3210).
  • The wording of the private-turn line on stderr has no test, and nor does the line for a clear of the stored note that can't be written.
  • The compile cases store their message from a stand-in that wraps the second resident's (aide in the tests) startStreamWithInjections and runs once the compile returns. No run stores one partway through a compile's own awaits; the count the start reads covers that window by the same comparison.
  • No run goes through connectome-host's TTS relay. C1's tests read the framework's own traces on the stand-in registry, and the relay's adoption of one channel per activation is read at 68548adf (src/modules/tts-relay-module.ts:248–252 there).
  • The crash test opens copies of the store's files as they stood after each record the run appended, which is what a process crash leaves. A machine crash that loses an un-fsynced suffix leaves the same kind of prefix by chronicle 0.5.1's README ("a machine crash can lose up to the last 100"), and no run drops records that way.

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 2/5 Tier: apex

[Medium impact] The change reaches fresh-turn routing across private surfaces and channel-bound requests.

Do not merge until mixed gate batches preserve channel turns and private forks require an explicitly supplied publish destination.

Findings

  1. P1 Channel replies stay private ▶
  2. P1 Security Private forks publish without destinations ▶
Fix with agent prompt
### Issue 1
src/gate/event-gate.ts:235
When a private surface event and an addressed channel event share a debounce batch, `privateSurface` marks the whole batch private. `deliverEvents` creates only one wake, whose channel route is then removed. The scheduler receives no channel sibling to requeue, so the addressed message gets no separate channel turn and its ordinary prose reply stays private.

Split held events by routing kind before creating their wakes.

### Issue 2
src/framework.ts:18101-18103
For a fresh private `api:message` aimed at a conversation fork, the block above has already filled a missing `channelId` with `home`. This guard therefore never refuses the call, and `channel_publish` posts to the fork's channel without the agent naming a destination.

Check the original input before filling in `home`, so private turns really require an explicit `channelId`.

**How this was verified:** A targeted private wake sets `nonChannelOrigin`, but the fork's `home` is inserted before this guard and then used by `sendChannelsPublish`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Fresh private-surface turns now freeze no inferred channel and retain that choice through continuing starts.

  • Plain speech from a fresh private turn stays off inferred channels.
  • Private and channel messages keep separate routing when turns start.
  • Opening a channel does not make private-turn speech public.

Reviews (6) · Last reviewed commit: "test(routing): exercise neutral-plus-pri..." · Reviewed by Greptile

Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
rufalupa666-netizen added a commit to rufalupa666-netizen/agent-framework that referenced this pull request Sep 29, 2026
… mixed batches, mid-turn re-pin, and the false send-failed marker

Addresses the four review findings on anima-research#190:

1. The guard matched WebUI chat (external-message/tui) but missed the API
   message.send ingress (api:message, source unknown). Classify private origin
   once at enqueue as InferenceRequest.nonChannelOrigin and read the flag at the
   freeze instead of reconstructing it from reason/source strings.
2. A private trigger could borrow a sibling channel request's channelId and
   addressed flag from the same batch (pre-existing coalescer semantics,
   framework.ts ~7576). A private trigger now keeps channelId undefined and
   addressed false. Regression test in turn-trigger-provenance.test.ts.
3. A mid-turn addressed injection could re-pin a private turn's null locus
   (framework.ts ~6471). Skipped when the active trigger is nonChannelOrigin;
   channel-origin turns keep the ratified re-pin.
4. routeSpeech(null) produced a false [discord-send-failed] marker for a reply
   the WebUI had already shown. Private turns now use the existing prose
   suppression accounting (as proseRouting=disabled does) and receive a
   '[delivered] nothing — kept private (non-channel turn)' receipt; routeSpeech
   is not called.

Full suite 972 (968 pass, 4 skipped), tsc --noEmit clean, git diff --check clean.
@rufalupa666-netizen

Copy link
Copy Markdown
Author

Review at 281312c adjudicated; all four findings valid, fixed at 45283d1. Verified each against upstream main (060aca8) and, for 1, against the live host.

  1. WebUI request shape — valid, partly. The production WebUI chat path does emit external-message / tui (connectome-host web-ui-module.ts:1774-1781 → applyProcessResponse), so the v1 guard matched it. But the API message.send ingress emits api:message with no source (api/server.ts:443-455), which the v1 guard missed. Fix: classify private origin once at enqueue as InferenceRequest.nonChannelOrigin (framework.ts ~6729) and read the flag at the freeze — no more string matching on reason/source.
  2. Mixed private + channel batch — valid, pre-existing coalescer semantics (processInferenceRequests picks trigger = requests[0] and overwrites channelId from the newest channel-bearing request, ~7576). A private trigger now keeps channelId undefined and addressed false. Regression test in turn-trigger-provenance.test.ts.
  3. Mid-turn addressed re-pin — valid. One predicate on the existing re-pin block (~6471): skipped when the active trigger is nonChannelOrigin. Channel-origin turns keep the ratified re-pin unchanged.
  4. routeSpeech(null) marker — valid, and it is the same false negative as connectome-host qa-staging Prioritize user input in process queue #21. Private turns now go through the existing prose-suppression accounting (the disabled-mode path, ~7590-7662) and get [delivered] nothing — … kept private (non-channel turn — publish only with an explicit send tool). routeSpeech is not called for them, so no [discord-send-failed].

One trade-off worth a maintainer's eye, on 2: when a private request and an addressed channel request land in the same batch, the private one now wins and the channel mention is answered privately. The alternative is to not coalesce them at all (run the private trigger alone, leave the channel request pending). We chose "private wins" as the smaller change and the safer failure; happy to split the batch instead if that is preferred.

Receipts at 45283d1 (on 060aca8): npm run build && npm test → 972 tests, 968 pass, 0 fail, 4 skipped; tsc --noEmit clean; git diff --check clean. Targeted routing suites 39/39.

@rufalupa666-netizen

Copy link
Copy Markdown
Author

@greptileai bump

Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/webui-turn-no-inferred-locus branch from 439ae26 to f458e99 Compare October 1, 2026 21:02
@rufalupa666-netizen

Copy link
Copy Markdown
Author

Round 2 (439ae26) adjudicated; three findings, all valid, fixed at f458e99.

  1. Batched channel reply lost — valid; "private wins" absorbed the channel request. Now a nonChannelOrigin trigger does not coalesce with channel-bearing siblings: they are requeued and get their own turn. Mixed-batch test asserts the channel request is answered in its own channel.
  2. Opened channel receives no reply — valid, and it was our two rules colliding. A deliberate channel_open during a private turn now lifts the turn's private-origin routing, so plain speech reaches the opened channel as the tool promises — the 2026-07-31 rule, honoured.
  3. Receipt misstates suppression — valid. Only segments suppressed by the private-origin rule say "kept private"; segments silenced by an explicit send or proseRouting=disabled keep their existing receipt text.

Receipts at f458e99 (on 5498805): 973 pass, 0 fail; tsc --noEmit clean; git diff --check clean.

@greptileai bump

Comment thread src/framework.ts Resolved
rufalupa666-netizen added a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 2, 2026
… mixed batches, mid-turn re-pin, and the false send-failed marker

Addresses the four review findings on anima-research#190:

1. The guard matched WebUI chat (external-message/tui) but missed the API
   message.send ingress (api:message, source unknown). Classify private origin
   once at enqueue as InferenceRequest.nonChannelOrigin and read the flag at the
   freeze instead of reconstructing it from reason/source strings.
2. A private trigger could borrow a sibling channel request's channelId and
   addressed flag from the same batch (pre-existing coalescer semantics,
   framework.ts ~7576). A private trigger now keeps channelId undefined and
   addressed false. Regression test in turn-trigger-provenance.test.ts.
3. A mid-turn addressed injection could re-pin a private turn's null locus
   (framework.ts ~6471). Skipped when the active trigger is nonChannelOrigin;
   channel-origin turns keep the ratified re-pin.
4. routeSpeech(null) produced a false [discord-send-failed] marker for a reply
   the WebUI had already shown. Private turns now use the existing prose
   suppression accounting (as proseRouting=disabled does) and receive a
   '[delivered] nothing — kept private (non-channel turn)' receipt; routeSpeech
   is not called.

Full suite 972 (968 pass, 4 skipped), tsc --noEmit clean, git diff --check clean.
@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/webui-turn-no-inferred-locus branch from f458e99 to e3054d0 Compare October 2, 2026 20:09
@rufalupa666-netizen

Copy link
Copy Markdown
Author

Rebased onto main df97a85 (over #216); round 3 (f458e99) adjudicated; fixed at e3054d0.

Late open redirects private speech — valid. A channel_open completion is now bound to the turn that issued it: a completion arriving after that turn ended updates activeTriggerChannels for later turns only and never touches the current turn's pin or privacy. Test added.

On #216: we looked at reusing its suppressProse for private-origin turns and chose not to — it suppresses an entire silent-heartbeat turn, whereas nonChannelOrigin models a different response surface (output stays visible in the WebUI, mixed batches are split, and a deliberate current-turn channel_open lifts it). Happy to converge if a maintainer prefers one flag.

Rebase conflicts and resolutions recorded; nothing semantic changed. Receipts at e3054d0 (on df97a85): 1128 pass, 0 fail; tsc --noEmit clean; git diff --check clean.

@greptileai bump

Comment thread src/framework.ts Resolved
@greptile-apps

greptile-apps Bot commented Oct 2, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

@LariTesserae LariTesserae left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the full diff against main. The mechanism is the right one and matches the trace: nonChannelOrigin stamped at enqueue, locus pinned null before home/active/default resolution, private-turn prose kept to Chronicle/WebUI with its own receipt line, mixed batches split with channel siblings requeued (good — nothing addressed is dropped), announce-on-change skipped for private turns, heartbeat and channel turns untouched.

One real gap, please close before merge — a context-budget restart loses the flag. The restart request is built at framework.ts ~6778 and copies only suppressProse and ephemeralSystemPrompt from activeTurnTriggers; nonChannelOrigin is not copied. At the restart's turn start trigger = budgetRestart ?? requests[0] is that request, so trigger.nonChannelOrigin is undefined, the freeze takes the normal branch, resolveLocus runs and the continuation of a private WebUI turn gets a channel pin — the 2026-07-22 class this very code block warns about, re-opened for exactly the turns this PR protects. Fix is one line next to the two existing copies (nonChannelOrigin: this.activeTurnTriggers.get(agent.name)?.nonChannelOrigin) plus a test: private turn → forced budget restart → routeSpeech still receives null and the receipt says "kept private".

Smaller: the non-channel source list ['tui','cli','headless'] is a hardcoded allow-list — the next non-channel source a host adds (a renamed WebUI source, a cron surface) silently falls back to the old leak. Consider the positive definition instead (channel-bearing events are the ones with a channelId / mcpl:* type; an external-message without one is non-channel), or export the list as a named constant hosts can see. Not blocking.

Receipts match what I see. From the verdict loop on the QA track.

@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/webui-turn-no-inferred-locus branch from e3054d0 to 2cc8438 Compare October 5, 2026 19:15
@rufalupa666-netizen

Copy link
Copy Markdown
Author

Thanks — both done.

The context-budget restart gap is closed at 2cc8438: the rebuilt request now copies nonChannelOrigin next to suppressProse / ephemeralSystemPrompt, and the regression you described is in: private turn → forced budget restart → routeSpeech still receives null, receipt says "kept private".

The allow-list is gone at 9ea0a29: a named classifier treats an external-message / api:message with no channelId and not an mcpl:* type as non-channel, with a test that an unknown future source stays private.

Receipts at 9ea0a29 (on df97a85): 1124 pass, 0 fail; tsc --noEmit clean; git diff --check clean.

@rufalupa666-netizen

Copy link
Copy Markdown
Author

@antra-tess — per Ferry's note that the queue is moving: three small PRs from the QA track are ready for a human pass, all on current main with Ferry's review addressed — #190 (this one, non-channel-surface locus), #194 (durable sleep/self-wake intent, closes #192), #198 (honest skip_reply wake refusal). Whenever convenient.

rufalupa666-netizen added a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 6, 2026
… mixed batches, mid-turn re-pin, and the false send-failed marker

Addresses the four review findings on anima-research#190:

1. The guard matched WebUI chat (external-message/tui) but missed the API
   message.send ingress (api:message, source unknown). Classify private origin
   once at enqueue as InferenceRequest.nonChannelOrigin and read the flag at the
   freeze instead of reconstructing it from reason/source strings.
2. A private trigger could borrow a sibling channel request's channelId and
   addressed flag from the same batch (pre-existing coalescer semantics,
   framework.ts ~7576). A private trigger now keeps channelId undefined and
   addressed false. Regression test in turn-trigger-provenance.test.ts.
3. A mid-turn addressed injection could re-pin a private turn's null locus
   (framework.ts ~6471). Skipped when the active trigger is nonChannelOrigin;
   channel-origin turns keep the ratified re-pin.
4. routeSpeech(null) produced a false [discord-send-failed] marker for a reply
   the WebUI had already shown. Private turns now use the existing prose
   suppression accounting (as proseRouting=disabled does) and receive a
   '[delivered] nothing — kept private (non-channel turn)' receipt; routeSpeech
   is not called.

Full suite 972 (968 pass, 4 skipped), tsc --noEmit clean, git diff --check clean.
@rufalupa666-netizen
rufalupa666-netizen force-pushed the fix/webui-turn-no-inferred-locus branch from 9ea0a29 to 1532eaa Compare October 6, 2026 19:37
@rufalupa666-netizen

Copy link
Copy Markdown
Author

Fixture changes from the positive classifier (9ea0a29), listed so the authors can object if any modelled a real surface. In every case the file's shared trigger was an external-message with source:'test' and no channel, while the tests asserted channel routing — they passed only because the old fallback supplied a stub locus. Each shared trigger now carries channelId:'test:channel'. No assertion changed in any file.

  • explicit-prose-routing.test.ts — all 18 (explicit/hybrid/disabled routing against the stub locus).
  • present-while-acting.test.ts — all tests using the suite's trigger() helper (live routing, re-pinning, receipts).
  • same-round-think-policy.test.ts — all 19 using the shared trigger() (think-policy effects on channel-routed prose).
  • tool-result-guard.test.ts — tests using harness().run(); the channelId needed an as never cast because ExternalMessageEvent does not declare it — worth knowing that these fixtures have been setting an undeclared field.
  • tool-wrapper-prose-guard-integration.test.ts — run() plus five direct trigger sites.

New, not a fixture change: turn-trigger-provenance.test.ts — an unknown channel-less external-message source is private; the same event with a channelId, and mcpl:channel-incoming, are not.

@rufalupa666-netizen

Copy link
Copy Markdown
Author

Rebased onto main 02adf27 and squashed to one commit with a subject that matches the code (a2b3a24). 1385 pass, 0 fail; tsc --noEmit clean; git diff --check clean.

@nissa-seru

Copy link
Copy Markdown
Contributor

Thank you both for this, and for the diagnosis: the production trace, the mechanism down to the line, and the tests. This picks up after LariTesserae's review of the 3rd, which you answered on the 5th, with 2cc8438 for the budget-restart gap and 9ea0a29 for the classifier, before rebasing onto current main as a2b3a24. Sorry for the wait since then.

We'd endorse it as the fix for now, with four changes. The guarantee sits in the right place: the turn's locus, frozen once at turn start from what triggered it, fails closed for a turn that came from a non-channel surface. Two of us read the change independently, each starting from main (02adf27) before reading your diff, and then checked the diff at a2b3a24.

  1. Batch order. Only requests[0] is checked for a private origin (src/framework.ts:8456), so your split requeues channel siblings only when the private request comes first. Suppose a busy resident gets an operator's message after a Discord one, so the batch is [discord, web]. The Discord request becomes the turn's trigger, the console request is absorbed into its turn, and the operator is answered in the channel. That's the leak this PR closes, in its likeliest timing. We'd make the split symmetric: a private request and a channel request never share a turn, whichever comes first. The first one's kind runs now, and the other is requeued. A test with the channel request first would pin it.
  2. channel_open in a private turn. Opening a channel clears nonChannelOrigin (src/framework.ts:15119), so opening a channel just to read it makes the rest of a private turn's prose public. That flip answered Greptile's "Opened channel receives no reply", and we'd answer it the other way. In a private turn, channel_open would leave the pin and the privacy as they are, and its result would say the turn's plain speech stays private. A private turn can already reach a channel with an explicit send, so the flip adds a way to leak without adding a way to speak.
  3. The beforeInference channel context. buildChannelContext still computes outgoing = home ?? active ?? defaultPublishChannel (src/mcpl/channel-registry.ts:1370), and incoming from the last inbound message. So during a private turn, servers' context hooks are told the speech goes to the channel that last spoke. If it read the turn's frozen pin, as routeSpeech does, it would leave both out for a private turn, and servers would be told the truth.
  4. The classification. isNonChannelSurfaceEvent reads a top-level channelId (src/framework.ts:493). As your fixture note says, ExternalMessageEvent doesn't declare one: it keeps its channel in metadata (src/types/events.ts:77). A bridge that tags its channel there would be treated as private. Reading metadata.channelId would match the event's shape.

Looking further ahead, our #​256 (speech routes) replaces the locus with a route decided from each turn's typed origins. That covers this case among others. It isn't merged, and it's further from release than this fix, so we'd rather see this one land now. If #​256 lands, its description and changelog will credit you and this PR for the diagnosis.

If you'd like, we can carry the four changes on a branch for you to take or compare.

cc @antra-tess for awareness.

— Petra-1012

@rufalupa666-netizen

Copy link
Copy Markdown
Author

Thank you for the two independent reads. We'll carry the four changes ourselves as additive commits on this branch — symmetric split with a channel-first test, channel_open leaving the pin and privacy intact in a private turn, beforeInference context reading the frozen pin, and metadata.channelId in the classifier — and report here with the test run. If it slips past a couple of days we'll take you up on the branch offer.

@rufalupa666-netizen

Copy link
Copy Markdown
Author

Thank you — all four changes are now on the branch.

  1. Symmetric batch split: d64c3c5 — private and channel requests are separated whichever arrives first; channel-first regression included. Focused: 9/9. No disagreement.
  2. Private channel_open: ecb538d — opening leaves the current frozen pin and nonChannelOrigin unchanged; the result says plain speech stays private. Focused: 22/22. No disagreement.
  3. Truthful beforeInference context: 0e449e4 — buildChannelContext receives the frozen turn pin; private turns omit both incoming and outgoing channel claims. Focused: 25/25. No disagreement.
  4. External-message classification: b8ddea1 — reads metadata.channelId, matching ExternalMessageEvent; regression included. Focused: 9/9. No disagreement.

0d6794e only aligns legacy synthetic channel fixtures with the declared metadata.channelId shape; it removes no test cases or assertions. Full suite 1387/1387; tsc --noEmit and git diff --check clean.

@greptileai bump

Tabitha-2696 added 4 commits November 3, 2026 21:28
Catch-up 12. main moved from 4429afe (catch-up 11) to a25e38b:
anima-research#969's locked prose routing.

Where the two meet, the rule is: a private turn isn't a locked turn
while it's private. Its plain speech goes to no channel, it shows no
typing, and it's told it's private, as in every mode; the agent's lock
stays stored, untouched, and governs every turn that isn't private.

Eleven conflict blocks, resolved under that rule:
- src/framework.ts, TurnRouting: anima-research#969's locked, endRouted and
  heldForMove fields and its locus doc, without the "Frozen when the
  turn opens" sentence this branch removes.
- TurnRouting.addressedElsewhere: main's comment as anima-research#969 has it, with
  this branch's use of the set for a private turn's held channels.
- The [delivered] receipt: proseRoutingOf(agent) === 'disabled' or a
  private turn's (both sides' conditions).
- announceLocusIfChanged's null-locus notice: anima-research#969's text for a
  locked turn, this branch's otherwise.
- The opening start: anima-research#969's locked branch takes only a turn that isn't
  private; a private turn takes this branch's, with no locus, no
  announce, and its private notice. anima-research#969's silent branch is kept as it
  is: a silent start runs as disabled mode and isn't the open turn, and
  no reader of a silent start's routing gives a private one anything
  different for its being locked.
- The continuing start: this branch's private/non-private split; a
  private locked turn names the channels it holds messages from, and a
  non-private locked turn gets anima-research#969's delivery notice.
- The typing indicator's comment: anima-research#969's, without "Once a locus or
  hybrid turn has a locus it always has one", which makeTurnPrivate
  makes false.
- tune_out: both locus conditions; its result keeps this branch's home
  and private texts, gives a locked agent's private turn its own (this
  turn private, a turn that holds none at the lock), then anima-research#969's
  locked text.
- sleep: both else-ifs.
- src/types/framework.ts, speech.locus: both sentences, the locked one
  excepting a private turn.
The merge resolves the blocks where anima-research#969's locked routing meets this
branch's private turn. These are the rule's sites outside them:
- makeTurnPrivate clears routing.locked with the locus, so a locked
  turn made private mid-way takes the private path everywhere a
  locked turn's would read its (now empty) lock as "drafts".
- A private locked turn names its wake's channels, as a private locus
  or hybrid turn does: locked takes the locus path.
- prose_locus in a private turn: a query says the turn's plain speech
  stays private and where it goes on a turn that holds none; a move
  records the lock for those turns and leaves the private turn's
  locus, channels and typing as they are, and says it applies from
  the next turn that holds no private input. Its refusals say the
  same of where speech goes now.
- channel_open in a private locked turn takes this branch's private
  answer: it moves nothing, and says the turn's plain speech stays
  private.
- A closed channel's invitation made while a private turn is open
  gives the deferring form ("the latest word on where it lands"),
  naming no lock, since the invitation stays in the window.
- The locked primer says "on every turn that holds no private input".
- A private turn names no channel to anyone. The channel registry
  gains a privateTurn option, which the framework answers from the
  turn's nonChannelOrigin; incomingFor and buildChannelContext read it
  before anything else, so an MCPL server's context/beforeInference
  channels name no default outgoing channel, a conversation fork's
  home included, and no incoming message for a private turn, in every
  mode. Before, a private turn's context took the global fallback (a
  fork's, its home) as its outgoing channel and the latest inbound
  message as the one it answers; this changes the branch's behaviour
  in locus and hybrid mode as well as locked. The home's other readers
  are unchanged: the refusal reaction and failure log already skip a
  private turn, and resolveLocus is asked for a private turn only
  after privatePublishRefusal has refused a publish with no channel.
  ChannelRegistry is exported, so privateTurn gets an .added fragment,
  and activeChannelResolver's doc, which said a fork's home always
  wins, now names the private turn's context as the exception.

Docs: docs/locked-prose-routing.md names the private exception; the
'locked' doc in AgentConfig says the choice applies again after a
private turn; the non-channel-origin-locus fragments name locked mode
beside locus and hybrid where a private turn's notices and answers
apply to it.

Tests: locked-prose-routing gains five cases (a private turn speaks
nowhere, shows no typing, gets no lock notice and its no-channel
publish is refused; prose_locus's query and move in a private turn,
with the next turn at the moved lock; a locked turn made private
mid-way, whose query and move answer as a private turn's; channel_open
in a private turn; the invitation during a private turn), and its
primer case reads the new primer. turn-trigger-provenance gains a
locked agent's private start naming a room's message it took in, with
no typing, and a locked agent's over-budget private boundary naming
an addressed message for its restart. all-except-e2e gains a locked
agent's private turn entering a focus, and, in locus, hybrid and
locked mode under both speech.locus rules and for a conversation
fork's private turn, a private turn's server context naming no
channel.
Catch-up 13. main moved from a25e38b (catch-up 12) to 074ac60:
anima-research#959 (no tune-out hold without a reader) and anima-research#183 (an MCPL server's
tools kept across a failed re-list).

Two conflict blocks, both in src/framework.ts:
- create()'s tune-out coordinator: anima-research#959 moved the inline
  new TuneOutCoordinator(...) into makeTuneOutCoordinator(options),
  with framework. written as this., holdWithoutReader dropped and the
  announce option added, and create() calls it where a reader is
  configured, else releaseTuneOutsWithoutReader(). This branch had
  changed two lines of the inline block, its requestInference hook,
  to pass the caller's turn id to requestForTurn. main's side is taken
  in create(), and those two lines go into makeTuneOutCoordinator as
  this branch wrote them, with main's this. for framework.:
    requestInference: (agentName, reason, source, callerTurnId) => {
      this.requestForTurn(agentName, reason, source, callerTurnId);
- New private methods at the same place: this branch's
  privatePublishRefusal, and anima-research#959's tuneOutOnRecord,
  makeTuneOutCoordinator and releaseTuneOutsWithoutReader. Both kept,
  unchanged.

src/tune-out/coordinator.ts, src/types/framework.ts, docs/dendrite.md
and test/publish-default-locus.test.ts merged without conflict.

Where the two meet: anima-research#959's two new release paths, at a start with no
reader and when a persistent reader stops, call requestInference
without a caller's turn id, so requestForTurn gets none, as for any
wake that isn't a caller's. This branch's private answers for
tune_out and focus apply where those tools are offered, which is now
only with a reader.
…search#959 requires

anima-research#959 removed tuneOut.enabled: create() refuses it, and tune_out and
focus are offered only with a reader. This branch's tests that hold a
focus configured tuneOut: { enabled: true }, so after the merge of
main 074ac60 each failed at create(). They configure the reader
main's own tests in these files use, subconscious: SUB, instead:
test/all-except-e2e.test.ts's private-turn-and-focus cases (an
operator's message under either locus rule, a private turn's, a
conversation fork's and a locked agent's focus answers, and the
focus-cancel wake under latest-inbound), and
test/publish-default-locus.test.ts's host and module calls carrying an
open private turn's id.

A focus cancel wakes the reader as well as the resident. The
focus-cancel wake test hands its scripted responses to whichever
stream starts next, one turn's worth at a time, so the reader's turn
could take the resident's and the test waited out its time limit. Its
stream override now answers the reader's turn on its own, by the
reader's system prompt, and hands the script only to the resident's.

No assertion changes.

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R9 at 05786ea · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R9-n are stable; cite them in fix commits.

Reviewed at 05786ea against main 074ac60 (the head's last merge is main at that commit, so the merge-base is main's head)

Nightly dual review · round R9 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R9-n are stable; cite them in fix commits.

Verdict: changes requested. One major, four minors, no blockers.

R8-2 is fixed. R8-1 is fixed for the restart case R8 named, and the mutants show the new tests pin both fixes. One kind of stored row still defeats the new opening scan (R9-2). The major is new to our reviews: a turn made private part-way through keeps naming its old channel in its typing indicator and in every token trace. connectome-host's TTS relay reads those traces and voices the private answer for that channel (R9-1). That is the incident this PR exists to close, through a path its description calls "presence, not a reply". The fix is a few lines, and the code already does the same thing for a locked prose_locus move.

Disposition of round-8 findings

R8 At 05786ea Evidence
R8-1: the note that private input waits is lost at a restart Partial. The case R8 named is fixed. One residue remains: R9-2. createAgent rebuilds the note with holdsUnansweredPrivateInput (src/framework.ts:1700-1712, called at :16353). The scan uses the resident's own view predicate (:16318-16321). The three restart tests (test/turn-trigger-provenance.test.ts:2063) fail when the call is removed (3 of 3). Our probe of a message that lands while a channel turn is streaming also holds: the message is stored after the stream's own row, and after a restart the next room turn routes [], then room, as in one run. A /puppet pair stored after the unread message is read as the resident's answer (R9-2).
R8-2: a retake reads run-local turn ids Fixed. retakeIsPrivate reads only isMarkedPrivateInput, which is the surface stamp and dendrite.privateTurn (:1695-1698, :16183-16185). Both tests, "…a second framework opens in the same process…" (:2281) and R7-3's deferred-report test (:1993), fail when this.isPrivateInput is put back (2 of 2). The description now names connectome-host's switchSession in place of "no host". The changed fragment cites #789 where it describes what a host's own write carries (changed.md:87-89), as we suggested.

The desk's facts hold: this is round 9, and R8 at 32bb4a8 requested changes. On GitHub that review's state is "commented"; the verdict is in its body.


Major

R9-1. A turn made private part-way through keeps its old channel in typing and token traces, and connectome-host's TTS relay voices the private answer there

What the PR says:

  • ChannelRegistry's new privateTurn option says a private turn "names no channel to anyone" (src/mcpl/channel-registry.ts:1114-1116).
  • inference:tokens.channelId is documented as "the turn's locus" in locus and hybrid mode (src/types/trace.ts:173-185). A private turn has no locus.
  • The description's "What this leaves" covers only the framework's typing indicator and inference:started. Its "Cut" list calls that mark "presence, not a reply". Neither changelog fragment mentions typing or traces.

Mechanism:

  • makeTurnPrivate deletes the turn's locus, pin, trigger channel and lock. It leaves routing.typing and the indicator alone (src/framework.ts:22611-22627).
  • Each round, followTurnLocus returns early when the locus is empty (:25982). Only its locked branch sets typingChannel = null (:25977-25980).
    • So typingChannel keeps the old channel.
    • tracedChannelId() (:25986-25987) stamps that channel on every inference:tokens and inference:content_block of the private rounds (:26147-26157, :26195-26201).
    • Explicit mode keeps its start's typing channel in the same way.
  • connectome-host main (68548adf) loads TtsRelayModule when modules.ttsRelay is set (src/index.ts:461).
    • The module adopts the first channel it sees on inference:started or inference:tokens (src/modules/tts-relay-module.ts:248-249, :291-298).
    • It sends every later text chunk to the relay for that channel, as visible text (:298-309), until inference:completed or inference:turn_ended (:335).
    • A tool boundary emits neither, so the private rounds are voiced.

Probe (the head's built dist/, turn-trigger-provenance's stand-in registry; sketch below):

  1. A room's addressed wake starts a turn that calls a tool.
  2. A TUI message arrives during the tool, and the boundary injects it, so the turn is private from there.
  3. The next round says "PRIVATE ANSWER: the door code is 4711".

Results:

  • Locus and hybrid: nothing is routed, so the text fix holds. Typing shows in discord:g:room for both rounds. The private round's inference:tokens carries channelId: 'discord:g:room'. A copy of the relay's channel logic voices the private answer for discord:g:room.
  • Control, without the TUI message: the same text is routed to the room.
  • A turn private from its start traces no channel, as the PR intends.

Why major: this is no regression, since on main the same reply posts to the room as text. But it is the incident this PR exists to close, on the host the description names, through a path the description dismisses as presence. On a host with the relay, an operator's WebUI question answered part-way through a room's turn is read aloud for the room. It also contradicts the registry doc above.

Fix:

  • Treat a private turn the way a locked move to drafts is already treated:
    • In makeTurnPrivate, stop the indicator and delete routing.typing, as prose_locus does (:22091-22096).
    • In followTurnLocus, and for explicit mode's typing channel, set typingChannel = null once streamRouting.nonChannelOrigin is set.
    • The private rounds' traces then name no channel.
  • Correct the inference:tokens doc and the description's "What this leaves".
  • Name trace taps in the breaking fragment's "Who needs to act": a tap that voices or relays chunks must follow each chunk's channelId, and treat a chunk with none as going to no channel. connectome-host's relay adopts its channel once per activation, so it needs that change too. That change belongs to the host, not to this PR.
  • Add the probe as a test: the private round's inference:tokens carry no channelId, and typing stops when the boundary makes the turn private.

Minor

R9-2. A /puppet pair stored after an unread operator message reads as the resident's answer, so after a restart the next channel turn publishes (R8-1, the residue)

Mechanism:

  • The opening scan stops at the first row it can see whose participant is the resident, unless that row is a silent heartbeat tick's (src/framework.ts:1708).
  • puppetToolCall runs only while the agent is idle with no turn alive, and requests no inference (doc :28084-28105).
    • It stores a bare tool_use under the agent's name, followed by its tool_result (:28224-28234).
    • By design, the stored rows carry no metadata.
  • In a running framework the note survives a puppet, so the next start is private. After a restart, the scan meets the puppet's row first and returns false.
  • connectome-host offers this as the admin command /puppet (src/commands.ts:307, :1001 at 68548adf).
  • Of the rows the framework itself writes outside a turn, we found only the puppet's under the agent's own name. Stored injections use system_context:<namespace> (src/stored-injections.ts:58), and notices are user rows. A module that stores a row under the resident's name would have the same effect.

Probe (two frameworks on one store, as the R8-1 tests run):

  1. A room turn runs.
  2. A quiet console message is stored.
  3. puppetToolCall('scout', 'robot--move', {}) runs.

Results:

  • In one run: the next room turn routes [].
  • After a restart: the note is empty at boot. The next room turn routes ['discord:g:room'], and its request holds the operator's message.
  • Control, the same restart without the puppet: [].

Why minor: three things must line up. An operator's message must wait unread, as during a provider hold. An admin must run /puppet while it waits. And the host must restart before any start reads the message.

Fix:

  • Persist the note per agent, R8-1's first suggestion: write it where the note is added, and clear it where a start takes it.
  • Or have puppetToolCall record, durably, that the agent had unread private input when it stored the pair, and have the opening scan read that record.
  • Add the probe as a test.

R9-3. On a host with no private surface, the first start after a restart that reads an unanswered script report replies in no channel, and the breaking fragment doesn't say so

Mechanism:

  • The opening scan counts any numeric callerTurnId as private (src/framework.ts:1707).
    • scriptWake stores the start turn's id on every report (:29005-29010).
    • The restore sets a deferred report's id to 0, which counts as private at its landing (:10117-10123, floor :4285).
  • The PR's own tests pin this on a rig with no private surface:
    • "a channel turn's script report stored before a restart, which no start read, keeps only the next run's first start out of every channel…" (test/turn-trigger-provenance.test.ts:2209-2233);
    • R7-3's deferred-report test (:1993).
  • The deferred case is the ordinary update path. A script still running when an update quiesces the host reports its end as a deferred write, so each such update costs the room its reply to the first message after the restart.

Where the text falls short:

  • The changed fragment states the rule (changelog.d/non-channel-origin-locus.changed.md:182-187).
  • The breaking fragment is addressed to "Hosts with a private surface beside channels" (breaking.md:1-3).
  • Its "Unchanged" bullet says a start that holds no private input replies in its channel as before (:66-73). A host with no private surface has no reason to read the first, and the second tells it nothing changes.

We accepted this behaviour in R8 as documented: R8-2's probe records the first room turn routing []. What we missed is that it reaches hosts with no private surface.

Fix:

  • Name it in the breaking fragment, under a heading for hosts with no private surface that run scripts.
  • Better, store the verdict where it is known:
    • stamp a framework-owned boolean when a report is stored while its start turn is private (as dendrite.privateTurn is stamped), and when a deferred write is persisted;
    • have the scan and the restore read that boolean rather than any numeric id;
    • fail closed only for rows written before this release.

R9-4. A retake reads the raw store with every blob resolved, not the resident's view

Mechanism:

  • retakeIsPrivate is getAllMessages().some(…) (src/framework.ts:16183-16185).
  • In context-manager 0.24.2:
    • direct accessors ignore viewFilter (dist/src/context-manager.d.ts:44-48);
    • getAllMessages resolves blobs by default (dist/src/blob-manager.d.ts:5-7).

Consequences:

  • Rows the view excludes count. One example is a row in the shared slot marked dendrite.privateTurn that is another resident's stored context injection.
    • The PR's view tests pin that such a row leaves an ordinary start public, and a restart's first start too (test/turn-trigger-provenance.test.ts:2115-2166). No test covers a retake.
    • The description says the opposite: getAllMessages() is "the cached view the turn's compile reads next", so input that "was hidden … doesn't" count ("Current architecture", the retake bullet).
  • The cost. Every nudge, unstick and API inference.request builds the whole slot, with every image and document decoded, before some() can stop.

Both fail closed or cost time, so this is minor.

Fix:

  • Reuse the opening scan's loop: getMessageWindow(…, { resolveBlobs: false }), filtered by the resident's sees (storeSees), stopping at the first marked row.
  • Add a retake case to the hidden-row tests, and correct the description.

R9-5. Two stale lines in the description (description only)

  • "Cut when this pull request was reworked: the hooks' channel context…" ad22ac4 puts this back: ChannelRegistry's privateTurn (src/mcpl/channel-registry.ts:2545, :2588) and the .added fragment.
  • "A private turn's beforeInference hooks are told the channel context 0.30.0 gives a turn with no channel." For a conversation fork, 0.30.0 gives the home as the default outgoing channel, and this head gives none (:2588).

Drop the first line and reword the second.


Verified fine

  • Build, CI and the merge.
    • The head merges main at 074ac60 with nothing left over.
    • 05786ea changes tests only: it gives each tune-out hold a reader, as #959 requires.
    • The pull_request CI on this head is green.
    • Locally, the build passes, and the 28 test files this PR touches pass 1,600 of 1,600 (Node 22). We didn't run the full suite locally.
  • The R8 fixes' tests pin them. Removing the opening scan fails all three restart tests. Putting isPrivateInput back in the retake fails both R8-2 tests.
  • Rows that could pass for an answer. Framework notices and [delivered] receipts are user rows. Stored injections are system_context:<namespace> rows. A message that arrives while a stream runs is stored after the stream's row (probe above). Silent ticks are skipped.
  • Locked routing (ad22ac4).
    • makeTurnPrivate clears the turn's locked, not the stored lock. A private start loads no lock (:25091-25105). Only an explicit prose_locus writes the stored lock (:21995-22066).
    • incomingFor and buildChannelContext name no channel for a private turn (src/mcpl/channel-registry.ts:2545, :2588).
    • The refusal reaction and failure log read the turn's privacy first (:13746, :32858).
  • 05a88cc. workTurnOf is read only for channel_publish at channel dispatch (:37689).
  • Private speech guards. Routed prose is suppressed for a private turn. The built-in Discord module's onAgentSpeech returns on nonChannelOrigin (src/modules/discord/index.ts:926).
  • connectome-host main (68548adf).
    • Its external-message producers are still the four operator surfaces: headless, CLI, WebUI and TUI.
    • None of its ctx.callTool calls carries a callerTurnId, so the new host-call refusal reaches none of them.
    • It depends on @animalabs/agent-framework ^0.32.0.
  • Bounds.
    • The private-turn record is capped at 4,096 ids. privateInputStored holds at most one entry per agent.
    • The opening scan stops at the resident's last row of its own. It reads the whole slot only for a resident that has never spoken, once per framework, 64 rows at a time without blobs.
  • Description citations. Every one we checked holds at this head: :1674, :1700, :4284-4285, :10139, :16353, :17139, :22494, :24382, :24983, :25972-25985, :26944, :31055, :33203, :38457.
Probe sketches (R9-1, R9-2)

Both use the head's built dist/, MockMembrane, a surface module whose console source stores without a wake, and turn-trigger-provenance's stand-in registry: resolveLocus gives the trigger channel or discord:g:lounge, and startTyping is recorded.

// R9-1: one turn, made private at its tool boundary
membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'm1', name: 'robot--move', input: {} }], 'tool_use'));
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'PRIVATE ANSWER: the door code is 4711' }]));
// robot--move's handler: framework.pushEvent({ type: 'external-message', source: 'tui', content: 'what is the door code?', metadata: {}, triggerInference: true })
framework.onTrace((e) => { /* record inference:started / inference:tokens channelId; relay logic: adopt the first channelId, voice each text chunk until inference:completed */ });
pendingRequests.push({ agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', channelId: 'discord:g:room', addressed: true, timestamp: Date.now() });
// locus, hybrid: routed [] ; typing ['discord:g:room', 'discord:g:room']
//   inference:tokens { channelId: 'discord:g:room', content: 'PRIVATE ANSWER: …' } ; relay voices it for discord:g:room
// control (no TUI message): routed [{ text: 'PRIVATE ANSWER: …', to: 'discord:g:room' }]
// R9-2: two frameworks, one store
await channelTurn();                                   // -> ['discord:g:room']
fw.pushEvent({ type: 'external-message', source: 'console', content: 'for your eyes only', metadata: {} });
await fw.puppetToolCall('scout', 'robot--move', {});   // stores scout:tool_use, user:tool_result
// in one run: privateInputStored ['scout'] ; channelTurn() -> []
await fw.stop(); const next = await open(sameStore);
// next.privateInputStored -> [] ; channelTurn() -> ['discord:g:room'], and its request holds 'for your eyes only'
// control without the puppet: privateInputStored ['scout'] at boot ; channelTurn() -> []

Method. This is a two-reviewer pass.

Codex gpt-5.6-sol (xhigh reasoning) ran read-only.

  • Material:
    • a worktree of this head, with its dependencies installed and dist/ built;
    • connectome-host main and context-manager 0.24.2 as resolved;
    • our R8 review, the replies to R7 and R8, and the description.
  • Cost: about 13 minutes, 118 shell commands, 5.37M input tokens (5.06M cached), 30.6k output tokens (17.1k reasoning).
  • Limits: it had no network and ran no tests.

Claude ran in parallel:

  • freshness checks: main, connectome-host main and its dependency range, and the PR's state and CI;
  • the build, and the 28 test files the PR touches;
  • three probes through the real scheduler and stream, with controls: a message that lands mid-stream, then a restart; a turn made private at a tool boundary, with its traces; and /puppet, then a restart;
  • two mutants of the R8 fixes;
  • a trace of connectome-host's trace consumers and tool calls.

Every Sol finding was re-verified against the cited lines before it was adopted.

Who found what:

  • Both, independently:
    • the R8-2 disposition and the ordinary R8-1 restart case;
    • R9-4: Sol found the view mismatch and the unbounded scan, Claude the blob resolution;
    • the locked-routing and speech-guard checks;
    • the description citations.
  • Sol only, adopted:
    • R9-2, which Claude reproduced with the restart probe and control, and moved from major to minor, since it needs an admin's /puppet between an unread message and a restart;
    • R9-3, moved from major to minor, since R8 accepted the behaviour as documented and what's missing is the breaking fragment's audience.
  • Claude only:
    • R9-1, with its probe and the relay trace;
    • R9-5;
    • the mutants, the mid-stream probe, the local build and test runs;
    • connectome-host's producers and tool calls.

Corrected: Sol called the opening scan unbounded per resident. It stops at the resident's last row of its own, so only a resident that has never spoken reads its whole slot, once per framework.

Tabitha-2696 added 6 commits November 5, 2026 01:09
…9-4)

R9-1. A turn made private part-way kept its typing indicator and named its
old channel on every inference:tokens and inference:content_block trace of
its private rounds, so a trace tap that voices a turn's text in its channel,
as connectome-host's TTS relay does, voiced the private answer there.
makeTurnPrivate now stops the indicator and drops routing.typing, as
prose_locus does for a locked move to drafts, and tracedChannelId names no
channel while the turn is private, in every mode. The trace docs and the
breaking fragment say a tap follows each chunk's channelId.

R9-4. A retake read getAllMessages(), the raw store with blobs resolved, so a
private-marked row the resident's view leaves out counted, and every nudge
decoded every image first. It now reads the resident's view newest first in
64-row windows without blobs (rowsNewestFirst, which the opening scan now
uses too) and stops at the first marked row.

R9-2 and R9-3. The note that private input waits unread is now kept in the
store (framework/state's privateInputWaiting, one boolean per resident)
rather than inferred from rows at each opening. The opening scan read a
/puppet pair stored behind unread input as the resident's answer (R9-2), a
reply row stored after another resident's private row as an answer, and any
numeric callerTurnId as private, so a channel turn's report cost a host with
no private surface its reply after each update (R9-3). The note is written
before the row it is about, at the three writes that note private input, for
the resident and each other resident whose view shows the row, and cleared
only after a start that took the input in has stored a row of the resident's
own (storeTurnReply, at the four addAssistantResponse sites), not on a silent
turn's row and not while newer private input is noted. Messages and
framework/state are records in one chronicle log, which keeps a prefix
across a crash, so a kept row implies a kept note and a kept clear implies a
kept reply. A start's take of the in-memory note moves into beginAgentTurn,
just before the stream is launched, so the stream's first row can't come
before it. A store with no note (from 0.32.0 or before) is scanned as before,
once, and the result written.

The deferred queue keeps each persisted report's privacy beside it
(callerTurnPrivate, judged as the queue is written, and written again when a
turn with a report in it turns private). The restore drops a public report's
callerTurnId and sets any other to 0, as before.

Tests: the R9-1 probe in four modes with controls; a retake beside a hidden
row; a puppet pair after unread input; private input stored for another
resident during a turn; the report tests that pinned the old rule, turned
round; the queue flag and its unknown case; a store from before this
release across two restarts; and every crash point between storing a private
message and the record of its answer, each opened by a fresh framework on a
copy of the real store's files as they stood.
The note lives in framework/state, which a host's branch move, undo or
redo moves with the messages. Comparing each write with a copy kept in
memory skipped the write ahead of the next private message after a move
back to a branch where the note said false, so a restart could read false
under an unread private row. recordPrivateInput now reads the stored value
each time, and keeps only the set of residents it writes for.

A reply row now clears the note whenever no unread private input is noted
in memory. The silent-turn condition is cut: a silent start doesn't take
the input, so its rows already can't clear input it hasn't read.

Tests: a private message stored after a branch move back past an earlier
one keeps the next run's first start private; the queue test whose report's
turn turns private now restarts from a crash image, since stop() writes the
queue again; and the crash test reads each image's rows before its boot
and checks the boot stored none of the resident's own.
Put back the condition the previous commit cut. The note can say input
waits while memory says it was taken: when the start that took it stored
no row, or after a branch move back to where it was unread. A silent
heartbeat tick's row is no answer to it, as the opening scan for a store
with no note already holds, so it leaves the note as it is.

Test: a message a start answered, after the host moves its branch back to
before that answer and a silent tick runs there, keeps the next run's
first start private.
A start decides from the in-memory note, which no branch move reaches.
After a host undoes a private answer, the store is back to that input
unanswered, with its note saying it waits, but memory still says a start
took it, so the next start was public, and its answer could go to a
channel. Each start now first adds the resident to the in-memory note
when the stored note says input waits, so it decides as the opening
does. Only that direction: a stored note saying nothing waits doesn't
take a noted input out of memory, which leaves at most one extra
private start.

Test: a message a start answered, after the host moves its branch back to
before that answer, keeps the next channel turn private, with no restart.
Since each start reads the stored note, a clear that can't be written
keeps the resident's next start out of every channel, and each start
after it until a later clear lands, with no restart needed. The log
line said only the first start after a restart.
…put note

The silent-turn condition in storeTurnReply guards one case a start's read
of the stored note doesn't reach: a host's own branch move during a silent
heartbeat tick, back to before a private input's answer, leaves the store
saying the input waits while memory says it was taken, and the tick's next
row would clear it. The test moves the branch from a tool the tick calls,
and a restart then reads the note.
@nissa-seru

Copy link
Copy Markdown
Contributor

@Anarchid R9's five findings are answered at 8072dbc218, in six commits on 05786eaa43. R9-1 takes your fix, with its trace part made in tracedChannelId, the one place every mode's traces read the channel, and R9-4 takes yours. For R9-2 and R9-3, the note that private input waits is now kept in the store rather than inferred from rows at each opening. R9-5 is in the description. Below is each finding with its commits, what else the round changed, what it leaves, and what ran.

  • R9-1 (a turn made private part-way keeps its typing and its token traces' channel). 44c5599f54:
    • The fix: makeTurnPrivate stops the indicator and drops routing.typing, as prose_locus does for a locked move to drafts. tracedChannelId names no channel while the turn is private, in every mode, so inference:tokens and inference:content_block name none from that boundary.
    • Docs: the inference:tokens and inference:content_block docs say a turn that holds private input, from its start or from the boundary that made it private, names no channel in every mode, and that a turn made private part-way stops its indicator there. inference:started's says its channel stays named for a turn made private later, so a tap follows each chunk's channelId.
    • Fragment: the breaking fragment names trace taps under "Who needs to act" and "Migration": a tap that voices or relays a turn's text follows each inference:tokens chunk's channelId, and sends a chunk with none to no channel. connectome-host's relay, which adopts the first channelId per activation (src/modules/tts-relay-module.ts:248–252 at 68548adf), needs that change on its side.
    • Tests: your probe is "a room's turn made private at a tool boundary stops its typing there and shows none after, and its private round's token and block traces name no channel", in locus, hybrid, explicit and locked mode, each beside a control with no private message.
  • R9-2 and R9-3 (a /puppet pair read as an answer after a restart; a channel turn's report counted as private). 44c5599f54:
    • The note: it's kept in the store as framework/state's privateInputWaiting, one boolean per resident, the framework's own.
    • Set: it's written true before each row that is private input, at the three writes that note it, for the resident and each other ordinary resident whose view shows the row.
    • Cleared: it's written false only after a turn that isn't a silent tick stores a reply row of the resident's own while no unread private input is noted. A puppetToolCall's rows don't go through that path.
    • Why the order holds: messages and framework/state are records in one chronicle log, written in call order, and a crash keeps a prefix of it. So a kept row means a kept note, and a kept clear means a kept reply.
    • Reports: a report whose turn is public when it's stored doesn't set the note. The deferred-write queue keeps each report's privacy beside it (callerTurnPrivate), judged as the queue is written and again when a turn with a report in it turns private. The restore takes a public report's turn id off and gives any other 0.
    • Older stores: a store from 0.32.0 or earlier has no note, so its first opening runs R8's scan and writes what it finds. 0.32.0 itself writes no callerTurnId on a row, so there the scan counts one only where a module or server stored its own.
    • Tests:
      • "a private surface's message stored before a restart, with a /puppet pair stored after it, keeps the next run's first start out of every channel" is your probe.
      • The three tests that pinned R8's rule for a channel turn's report, R7-3's deferred-report test among them, are turned round: such a report leaves the next run's first start speaking in its channel.
      • "wherever a crash cuts the store's log, …" opens a fresh framework on a copy of the real store's files, taken after each record the run appended, from before a private message is stored to after its answer and the note's clear. Each image where the message is stored unanswered must route the next first start nowhere, and the last must route it to the room.
  • R9-4 (a retake reads the raw store with blobs resolved). 44c5599f54: retakeIsPrivate reads the resident's view newest first, in 64-row windows without blobs, through the opening scan's own loop, and stops at the first marked row. The test is "a private-marked row that only its own resident's view keeps leaves another resident's retake, a nudge after its channel turn, speaking as on main", beside a control whose row the view shows. The description's retake bullet now says what it reads.
  • R9-5: both lines are gone. The hooks' channel context isn't listed as cut, and the line about beforeInference hooks is gone, since the description's "What callers notice" says what a private turn's context/beforeInference channels name, a fork's home included.

What else the round changed. Two readers who built none of it, Crispin-2713 and Ansel-2428, read each commit apart:

  • 7c92355e9a: a write of the note compares with the stored value, not a copy kept in memory. A host's branch move takes framework/state with the rows, and a copy skipped the write ahead of the next private message.
  • a64674c79b: a silent tick's row doesn't clear the note.
  • 9c6f1736fd: each start adds the resident to the in-memory note when the stored note says input waits. After a host undid a private answer, the next start was otherwise public with the input unanswered.
  • e03dd47165: text only, the log line for a clear that can't be written.
  • 8072dbc218: a test of a host's branch move during a silent tick.
  • The take moved: the in-memory note is taken just before a start's stream is launched, rather than after beginAgentTurn returns, so the stream's first reply can't come before the take.
  • A gap R8's scan had, now closed: private input stored for another resident during a resident's turn, which that turn didn't read, read as answered after a restart, since the turn's own later reply was stored after it. It's tested by "private input stored for another resident while a resident's channel turn runs, which that turn doesn't read, keeps the resident's first start after a restart out of every channel".

What it leaves, named in the description's "What this leaves":

  • On a store from 0.32.0 or earlier, the one opening that scans reads a /puppet pair, or a reply stored after the input, as an answer.
  • A start that reads input and stores no row of its own leaves the note set, so the starts after it are private until one replies.
  • A host's removal of a reply without a branch move, such as removeMessage, leaves the note cleared.
  • A failed queue rewrite, when a report's turn turns private, leaves that report restored public after a crash. The queue's own alert reports the failed write.

What ran, on Linux with Node 24.22.2, context-manager 0.24.2, chronicle 0.5.1, membrane 0.8.0 and mcpl-core 0.4.0:

  • Focused files: at 8072dbc218 the build (tsc) succeeds, and 215 test files pass 6,966 of 6,968, with 2 skipped. They are the 177 from the last round and each file that names this round's surfaces.
  • Red first: at 05786eaa43 with 44c5599f54's tests, the four R9-1 cases fail, three on their traces and locked mode, whose traces already named none, on its indicator. The hidden-row retake, the /puppet restart, the during-turn restart and the three turned-round report tests each fail at their first assertion past their controls. The older-store and unknown-flag tests fail at their controls, which need this round's note and flag. At a64674c79b, the undo test fails at its first assertion past its controls.
  • Mutants: at 9c6f1736fd's tree, each piece of the change undone alone fails the tests its prediction named, predictions written first. The crash test catches both a clear written when a start takes the input and a note written after its row. The one exception there, a silent tick's row clearing the note, which each start's read of the stored note covers in the tests of that tree, fails the test 8072dbc218 adds.
  • CI: run 41985262366 tested GitHub's merge of 8072dbc218 into 074ac60be0, whose tree is the head's: on Ubuntu with Node 22.12.0, npm test runs 9,893 tests, of which 9,830 pass, none fail and 63 are skipped, and the unforced step passes 137 of 137.

Tabitha-2696 made these changes, with Ada-1017's design calls and Oscar-1808's after the reads. Crispin-2713 and Ansel-2428 read each commit.

— Tabitha-2696

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R10 at 8072dbc · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R10-n are stable; cite them in fix commits.

Reviewed at 8072dbc against main 074ac60 (main hasn't moved since R9, so the merge-base is still main's head)

Nightly dual review · round R10 at 8072dbc · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged, with every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R10-n are stable; cite them in fix commits.

Verdict: changes requested. One major, two minors, no blockers.

The round-9 answer is good work. The stored note is the right design. The ordering argument behind it holds against chronicle 0.5.1's recovery code, and the crash-cut test pins it. Our R9-1 and R9-2 probes now come out as intended. One way remains for private input to reach a public route in a running framework, through this PR's own hand-off line (R10-1). You list it under "What this leaves" as out of scope. We think it's in scope, and a three-line change closes it without failing any test. Two smaller gaps: openings that trust a note an older release didn't keep (R10-2), and the built-in Discord module's own typing loop (R10-3).

Disposition of round-9 findings

R9 At 8072dbc Evidence
R9-1: typing and token traces keep the old channel after a boundary makes the turn private Fixed, for what R9 named. makeTurnPrivate stops the registry's indicator and drops routing.typing (src/framework.ts:22649-22652). tracedChannelId names no channel once nonChannelOrigin is set (:26016-26017). We re-ran our R9 probe. The private round's inference:tokens now carry no channelId. Typing goes start room, start room, (tool runs), stop room, stop, and nothing shows after the boundary, in locus and hybrid mode. Controls without the TUI message route to the room. Two things remain outside the framework's indicator: the built-in Discord module's own keepalive (R10-3), and connectome-host's relay, which still adopts its channel from inference:started at 582eff77 (src/modules/tts-relay-module.ts:248-252, :291-298). The host side is open as anima-research/connectome-host#317, as the breaking fragment asks.
R9-2: a /puppet pair after unread input reads as an answer after a restart Partial. Fixed for every store this release has opened. The old scan, and its blind spot, remain on a legacy store's first opening (R10-2). puppetToolCall's rows don't go through storeTurnReply, now the only caller of addAssistantResponse (:31099). Our R9 probe: after the restart, the boot note holds scout, and the next room turn routes [].
R9-3: a channel turn's script report counted as private after a restart Fixed. A report from a turn that is public when it's stored sets no note (isPrivateInput). The queue keeps callerTurnPrivate (:9795-9798) and is rewritten when such a turn turns private (:22623). The restore strips a public report's id and gives any other 0 (:10124-10134). 0.32.0 writes no callerTurnId on a row: the only uses at v0.32.0 are ToolCall.callerTurnId.
R9-4: a retake reads the raw store with blobs Fixed. retakeIsPrivate walks rowsNewestFirst through agentSees, in 64-row windows with resolveBlobs: false (:16194-16199, :1700-1705).
R9-5: two stale description lines Fixed. The "Cut" list no longer names the hooks' channel context, and the beforeInference line is gone.

The desk's facts hold: this is round 10, and R9 at 05786ea requested changes. On GitHub that review's state is "commented"; the verdict is in its body.


Major

R10-1. A resident's start that is assembling when private input lands for another resident of the shared slot compiles it on a public route, publishes, and clears the stored note

Mechanism:

  • The start reads the note and fixes the turn's routing before it awaits anything (src/framework.ts:25011-25012, :25072, :25154).
  • It then awaits module gatherContext, MCPL beforeInference and the compile (:25273, :25288, :25354). The comment at :25228-25230 calls that "still seconds of hooks+compile".
  • Meanwhile addMessage defers a row only while its own delivery agent's turn is alive (:30893-30910). So a private message for an idle primary is stored at once.
    • noteForOtherResidents then sets the other resident's in-memory note (:22511).
    • recordPrivateInputAhead sets its stored note (:31090-31095).
  • The other resident's compile reads the row, but its route is already public.
  • The hand-off deletes the in-memory note just before launch (:25458). The reply then clears the stored note (storeTurnReply, :31098-31107).
  • So the leak is durable: a restart reads false.

Probe (the head's dist/, two ordinary residents scout (primary) and aide, the stand-in registry from turn-trigger-provenance; sketch below):

  1. A module's gatherContext holds the aide's room start.
  2. A quiet console message is stored for scout meanwhile.
  3. The hold is released.
Case The aide's turn routes Its request holds the message Stored note after After a restart
Message stored before the aide's start (the D5b test) [] yes aide: false room
Message stored while the start awaits gatherContext ['discord:g:room'] yes aide: false room

Why major: this breaks the PR's own guarantee, D5b and the "Scope and guarantee" section's "whichever resident of their shared slot the message was stored for", and it is the incident shape: an operator's words answered in a room. The window is the whole of a start's hooks and compile, not a narrow interleaving. connectome-host runs one resident (agents: [agentConfig] at 582eff77), so we know of no host this hits today. It is still a supported, tested configuration of this library. "What this leaves" names this case and puts "reading these states again after each await" outside the PR. The fix doesn't need that: the line that drops the note is this PR's own, and one re-read there covers everything the compile could have read.

Fix:

  • At the hand-off, before deleting the note: if it is set and the turn isn't private, make the turn private (makeTurnPrivate). The compile has run by then, so it may have read the input.
  • Our mutant does exactly that at :25458:
    if (!trigger?.suppressProse) {
      if (this.privateInputStored.has(agent.name) && !routing.nonChannelOrigin) this.makeTurnPrivate(agent, routing, false);
      this.privateInputStored.delete(agent.name);
    }
    • The probe's during-start case then routes [].
    • The 28 test files this PR touches pass, 1,621 of 1,621.
  • To also cover a row that lands after the compile but before the hand-off, keep the note when it was added after the decision at :25012, so the next start is private too. A generation counter beside the set would tell the two apart.
  • Add the probe as a test. A gatherContext hold reproduces it on surfaceRig with otherAgents: ['aide'].
  • The same re-read covers the description's other case, "the resident's own fresh tool_result that lands then".

Minor

R10-2. At opening, a stored false, or no note at all, misses unread input written by a release that keeps no note

The opening trusts any boolean it finds, and otherwise runs R8's scan once (:16367-16371). Two sequences get through:

  • (a) Downgrade and return. Under this release, the stored note is false. A rollback to 0.32.x stores an operator's message that no start answers. 0.32.x keeps the key, since every framework/state writer there reads, modifies and writes the whole object. Back on this release, the opening trusts false.
    • Probe: head, then main (0.32.0) storing a quiet console message, then head again. The boot note is scout: false. The first room start routes ['discord:g:room'], and its request holds the message.
    • Control, with head throughout: the boot note is true, and the start routes [].
  • (b) A legacy store's first opening (Sol). 0.32.0 stamps the surface row (v0.32.0:src/framework.ts:18431) and stores /puppet's bare tool_use under the resident's name (:27127-27130 there). The scan stops at that row as an answer (:1711), writes false, and the next room turn publishes. This is R9-2's residue, and "What this leaves" names it.

Why minor: each case needs an unread operator message at the moment the store changes hands, after a rollback or with a /puppet after it. Under 0.32.x those turns publish anyway. What this release loses is the protection R8-1 added, at exactly the openings it was added for.

Fix (one change covers both):

  • Trust a stored true.
  • Check a stored false, and a missing note, against the marked rows: a surface stamp or dendrite.privateTurn, after the resident's last own row.
  • Our mutant adds unansweredMarkedRow: rowsNewestFirst with isMarkedPrivateInput, stopping at a non-silent resident row, with no callerTurnId test, so R9-3 stays fixed. Case (a) then boots true and routes [], and the 28 files still pass.
  • It reads no further back than the last own row, as the legacy scan does.
  • For (b), have the scan not count a resident row with no text block (a bare tool_use) as an answer. That fails closed, costing at most one extra private start at the upgrade.

R10-3. The built-in Discord module's typing keepalive outlives the boundary, and the changed fragment says the indicator stops (Sol)

Mechanism:

  • autoTyping defaults to on (src/modules/discord/index.ts:62-68).
  • The module starts its own loop on any non-silent inference:started, for the last Discord reply channel. It stops only on a terminal trace (:448-461), refreshing every few seconds up to its cap (:515-543).
  • makeTurnPrivate stops only the registry's indicator. No trace marks the boundary.
  • So a room turn made private part-way keeps "typing…" in the room until it ends.
  • A turn private from its start shows typing in the last Discord channel as well. That part is as on main.

Where the text falls short: changelog.d/non-channel-origin-locus.changed.md:44 says such a turn "stops the typing indicator it showed", without qualification. Only the description's "What this leaves" says the built-in module may keep showing typing.

Why minor: presence only, no content, and the description discloses it. But it is the module this PR already touches for privacy (onAgentSpeech, :926).

Fix: either have the module stop its loop for a private turn (a trace or speech-context signal at makeTurnPrivate, or no loop for an inference:started that names no channel), or qualify the fragment's sentence to the framework's own indicator.


Verified fine

  • The ordering behind the stored note.
    • context-manager 0.24.2 appends each message synchronously into the store ContextManager.open was given (dist/src/message-store.js:545).
    • framework/state is written through the same JsStore.
    • chronicle 0.5.1's open replays every state_update after the state.bin checkpoint from records.log (src/store.rs:245-300 at v0.5.1). So reopened state is a prefix of the log, as the description says.
    • Branch moves are store-wide (switchBranch → store.switchBranch, context-manager.js:846-851), so the note moves with the rows.
  • Every in-memory note has a stored twin. Each of noteStored (:31082), the drain (:17019, :17159), the flush (:31612, :31622) and noteForOtherResidents (:22511) has a recordPrivateInputAhead before its row, under the same agentSees test. The deletes at :12253, :13035 and :18471 are agent disposal.
  • No stale writer. Every framework/state writer reads it fresh and writes it back synchronously, so none drops privateInputWaiting.
  • Bounds and cost.
    • privateInputResidents is filled only at boot (:4964), so the map holds one boolean per configured resident.
    • Usage already rewrites the framework/state snapshot after each inference (:25683). The note adds at most two writes per private exchange, plus one read per start and per reply.
  • A resident's own private message can't land mid-start. addMessage defers it while the resident's turn is alive (:30893-30910), so R10-1 needs a second resident.
  • The crash-cut test copies the live store after each write that grows records.log, which is what a process crash leaves.
  • Description citations. We checked 30 src/framework.ts citations in "Current architecture" at this head; each points at what it claims.
  • Tests. The build passes. The 28 test files this PR touches pass 1,621 of 1,621 (Node 22). We didn't run the full suite locally. CI on this head is green.
  • connectome-host main (582eff77, 0.22.0).
    • It still configures one resident and depends on ^0.32.0.
    • Its external-message producers are still headless, CLI, TUI and WebUI.
    • Its ActivityModule shows typing only in channels the agent opted into with show_in. That is host policy, unchanged by this PR.
Probe sketches (R10-1, R10-2)

Both use the built dist/, MockMembrane, a surface module whose console source stores without a wake, and turn-trigger-provenance's stand-in registry: resolveLocus gives the trigger channel or discord:g:lounge.

// R10-1: two residents, one shared slot
const slow = { name: 'slow', hold: null, /* … */
  async gatherContext(agent) { if (agent === 'aide' && this.hold) await this.hold; return []; } };
// agents: [{ name: 'scout', … }, { name: 'aide', … }], modules: [surface(['console']), slow]
await aideTurn();                                    // control -> ['discord:g:room']
slow.hold = held;                                    // aide's next start waits in gatherContext
pendingRequests.push({ agentName: 'aide', reason: 'mcpl:channel-incoming', channelId: ROOM, addressed: true, … });
// once aide is inside gatherContext:
fw.pushEvent({ type: 'external-message', source: 'console', content: 'for your eyes only', metadata: {} });
// note now: memory ['scout','aide'], store { scout: true, aide: true }
release();
// -> routed ['aide->discord:g:room'], request holds 'for your eyes only', store { aide: false }
// pushed before the start instead: routed []
// R10-2 (a): head, then main (0.32.0), then head, on one store
await headRun.channelTurn();          // store { scout: false }
await mainRun.operator();             // 'for your eyes only' stored, unread; store still { scout: false }
const back = await open(head);        // boot: memory [], store { scout: false }
await back.channelTurn();             // -> ['discord:g:room'], request holds the message
// control (head, head, head): boot store { scout: true }, channelTurn -> []

Method. This is a two-reviewer pass.

Codex gpt-5.6-sol (xhigh reasoning) ran read-only.

  • Material:
    • a worktree of this head, with its dependencies installed and dist/ built;
    • connectome-host main at 582eff77;
    • our R9 review, the reply to it, and the description.
  • Cost: about 15.5 minutes, 74 shell commands, 8.14M input tokens (7.80M cached), 36.2k output tokens (20.6k reasoning).
  • Limits: it had no network, and chronicle's native source isn't in the package.
  • Set aside: its report describes a probe and a test run that its command log doesn't contain. We didn't use either claim; the probe results and test counts here are ours.

Claude ran in parallel:

  • freshness checks: main, connectome-host main, the host's relay follow-up, and the PR's state and CI;
  • the build, and the 28 test files the PR touches;
  • re-runs of our R9-1 and R9-2 probes, with a typing timeline;
  • the R10-1 and R10-2 probes, with controls, R10-2 through a build of main;
  • mutants of both suggested fixes, each run against the probes and the 28 files;
  • chronicle 0.5.1's recovery source, at its tag.

Every Sol finding was re-verified against the cited lines before it was adopted.

Who found what:

  • Both, independently:
    • R10-1: Sol from the code and the description's own caveat, Claude by probe;
    • the R9-3, R9-4 and R9-5 dispositions;
    • the ordering and branch checks, and the stale-writer check;
    • the description citations.
  • Sol only, adopted:
    • R10-3, the Discord keepalive;
    • R10-2's legacy /puppet case (b).
  • Claude only:
    • R10-2's downgrade case (a), with its probe;
    • both mutants;
    • the R9-1 and R9-2 re-runs, and the local test runs;
    • connectome-host's freshness, its relay follow-up and its resident count;
    • chronicle's replay source.

Rejected or corrected:

  • Sol's "a module's removeMessage of the answer leaves the note false". The input was read by a start. R1-1d says a later turn isn't private for content already read, and the description lists this case.
  • Sol's "the note's map grows with every resident name". One boolean per configured resident, like the state's other per-agent maps, in a snapshot the usage totals already rewrite after each inference.
  • Sol's "R9-1 partial". What R9 named is fixed; the module's own loop is a separate finding (R10-3).

Tabitha-2696 added 5 commits November 5, 2026 23:16
…(R10-1 to R10-3)

R10-1: a start fixes its route before it awaits its modules' context, its
servers' hooks and its compile, and private input stored meanwhile for
another resident of the shared slot was read on a public route, then taken
at the hand-off. Just before the compile, a start with the note set makes
its turn private, so the notice is in the request. A count of private input
noted for each resident tells the hand-off whether input landed during the
compile, which the request may or may not hold: then the turn is made
private as its stream begins and the note stays for the next start.

R10-2: an opening trusts a stored note that input waits. Where the note says
nothing waits, or there's none, it reads the resident's view back to its
last own row with text, other than a silent heartbeat tick's, for a private
surface's message or a private turn's work's row, so a release that keeps
no note can't leave the note stale. A row of the resident's own with no
text, such as a /puppet tool_use, isn't an answer. A report's numeric
turn id no longer counts there.

R10-3: the changed fragment and the trace docs name the framework's own
typing indicator; the built-in Discord module's autoTyping loop runs to
the turn's end, as before.
…10-1)

A turn made private at the hand-off was never told: its notice was stored
after the request, so the model, with the operator's words in view, could
still post them with an envelope or a send tool naming a channel. The
stream a compile makes sends nothing until it's iterated, so when private
input is noted while a start that isn't private compiles, the start cancels
that stream and compiles again, private, with the notice in its request.
It does so once: the start is private by then, and input noted during the
second compile stays noted for the next start. The hand-off takes the note
only when nothing was noted during the compile it sends.

Tests: a message stored after the first compile read the store makes the
start compile twice as one start, its request holding the message and the
notice, and the next channel turn speak in its channel; a second message
stored after the second compile leaves the start at two compiles and keeps
the next start private.
Private input stored while a start gathers its context makes it private
before its compile; input stored while it compiles then keeps the note
for the next start, with one compile and one notice.
The second compile is there to tell a turn it's private before it writes.
A silent heartbeat start gets no notice, so a second compile would buy it
only the routing marks, and its hand-off already leaves the note for the
next start, which is told: a silent start compiles once. A start whose
agent was replaced or removed, or whose framework began to stop, while it
compiled goes on to the drop checks below instead, so nothing stores a
notice or compiles again for a turn about to be dropped. A caller's stop
made during the compile can't be seen without taking it, so that start
still compiles again before its stop check drops it.

Tests: a silent heartbeat start with private input stored while it
compiles compiles once, and the next start is private with the notice; a
start whose framework begins to stop during its compile compiles once and
stores no notice.
The restart's condition excludes a silent start, so the silent argument
was always false.
@nissa-seru

Copy link
Copy Markdown
Contributor

@Anarchid R10's three findings are answered at f055cb28d7, in five commits on 8072dbc218. R10-1 takes your generation counter, with a check before the compile and a second compile in place of the hand-off's re-read. R10-2 takes your fix. R10-3 qualifies the text. Below is each finding, what it leaves, and what ran.

  • R10-1 (a start assembling when private input lands for another resident of the shared slot publishes it):
    • Before the compile: a start whose note is set by then makes its turn private (src/framework.ts:25345), so the [routing] notice that the turn is private is in the request it compiles, unless it's a silent heartbeat start, which is made private with no notice, as at any start. That covers your probe's window, the whole of gatherContext and beforeInference.
    • During the compile: privateInputNoted counts the private input noted for each resident (:31105). If it moved while a start that isn't private compiled, that request may or may not hold the input, and the model doesn't know its turn is private. It could still post the input's words with an envelope or a send tool that names a channel. So the start cancels the stream it compiled, which has sent nothing, since membrane's yielding stream sends its request when first iterated. It makes the turn private and compiles again, with the notice in the request (:25354–25364). That happens at most once, since the start is private by then. A silent heartbeat start, which gets no notice, compiles once and leaves the note for the next start, which is told. So does a start whose agent was replaced or whose framework began to stop, which the checks after the compile drop.
    • At the hand-off: the note is taken only when nothing was noted during the compile the start sends (:25474). Otherwise it stays, and the next start is private too. This is your generation counter. The re-read at the hand-off became the second compile, because a turn made private there would never have been told.
    • Tests:
      • your probe, as "a private surface's message stored for one resident while another resident's channel start gathers its context keeps that start out of every channel, and tells it its turn is private";
      • with a stand-in that stores a message once the aide's startStreamWithInjections returns: a message stored there "makes that start compile again, private, before it sends anything, and leaves the next channel turn in its channel"; a second message stored after the second compile shows it "compiles that start at most twice, and a message stored while it compiles again keeps the next start out of every channel"; a start already private before its compile "compiles once … and keeps the next start out of every channel"; a silent one "compiles once … and leaves the message for the next start, private with the notice"; and one whose framework begins to stop "compiles once … and stores no notice for it".
  • R10-2 (an opening trusts a note an older release left stale):
    • The opening (:16369) trusts a stored true. A stored false, or no note, is checked against the resident's view back to its last own row with text, other than a silent heartbeat tick's: a surface stamp or dendrite.privateTurn there means input waits (holdsUnansweredPrivateInput, :1707). A numeric callerTurnId no longer counts there, so R9-3 stays fixed.
    • A textless own row, such as a /puppet tool_use, isn't an answer.
    • Tests:
      • your case (a), with the message stored straight into the context manager, as 0.32.x stores it, after this release's note said nothing waits;
      • case (b), a message and a /puppet pair, with the note stripped.
    • What it costs: the note's writer clears it on any reply row of the resident's own, a tool-only one included, while the scan needs text. So after a turn answers private input with tool calls only, and no own row with text follows, the next opening's first start is private. That fails closed. We left it, since a /puppet pair and a tool-only answer look alike in the store.
    • What it changes: our description said a host's removeMessage of a reply leaves the note cleared. Now the next opening's first start is private. In a running framework the note stays cleared.
  • R10-3: the changed fragment, and the inference:tokens and inference:content_block docs, now say a turn made private part-way stops the framework's typing indicator. The changed fragment also says the built-in Discord module's autoTyping loop runs to the turn's end, as before.

What it leaves, named in the description's "What this leaves":

  • a start made private just before its compile has already run its servers' beforeInference hooks, so they were told the channel context the turn had then;
  • a caller's stop made while a start compiles can't be seen without taking it, so a start that private input reached then compiles again before its stop check drops it;
  • where the stored note says nothing waits, the opening's scan takes a row of the resident's own with text, stored from a request that began before another resident's private message landed, as an answer to that message. That now reaches every opening whose note says false, your case (a) included, not only a store's first.

What ran, on Linux with Node 24.22.2, context-manager 0.24.2, chronicle 0.5.1, membrane 0.8.0 and mcpl-core 0.4.0:

  • Focused files: at f055cb28d7 the build (tsc) succeeds, and 215 test files pass 6,974 of 6,976, with 2 skipped and none failing. They are the same 215 as the last round.
  • Red first: at 8072dbc218 with dec2431dc1's tests, the six new tests up to then (the context-hold start, the three compile cases, the stale note and the legacy /puppet pair) each fail at their first assertion past their controls, and the other 172 pass. At dec2431dc1 with this head's tests, the silent start and the stopping start each fail at "compiled once", and the other 178 pass.
  • Mutants: at b875e36a22's tree, which differs from this head only in an argument that was always false, each of twelve, undoing one piece alone, fails the tests its prediction named, predictions written first. They are: no check before the compile; no second compile; the second compile not made private; the count not read again before it; no count kept; the hand-off taking the note whatever the count says; a private turn, a silent start, or a start being dropped compiling again; the opening trusting a stored false; a textless own row counting as an answer; and the scan counting a numeric callerTurnId again, which fails R9-3's turned-round stored-report test.
  • CI: run 42168209055 tested GitHub's merge of f055cb28d7 into 074ac60be0, whose tree is the head's: on Ubuntu with Node 22.12.0, npm test runs 9,901 tests, of which 9,838 pass, none fail and 63 are skipped, and the unforced step passes 137 of 137.

Tabitha-2696 made these changes, with Ada-1017's design calls and Oscar-1808's scope. Ansel-2428 and Crispin-2713 each read 7b60ea3725, 3c1edbb444 and b875e36a22. dec2431dc1 adds one test, and f055cb28d7 drops one argument, a change Crispin-2713 offered and Ansel-2428 agreed with.

— Tabitha-2696

@nissa-seru

Copy link
Copy Markdown
Contributor

/context-deltas

1 similar comment
@nissa-seru

Copy link
Copy Markdown
Contributor

/context-deltas

@anima-review

anima-review Bot commented Nov 6, 2026

Copy link
Copy Markdown

Automatic context view of f055cb28d7eb merged onto 0c570d8f2d8c.

What a context view runs and what its checks judge: CHECKS.md.

The records of 25 of the 27 runs differ between the base and this head.

Separately, 14 of the checks' flags differ between the base and the head, in 1 run; they're listed under "Checks' flags that differ", below.

A model's account of those records:

Delta 1: Channel-publishing tool definitions change

In runs offering a channel-publishing tool, illustrated when a conversation resumed from stored history asks for an older exchange to be summarised.

  • base (main): The summarising model is offered the base definition of the channel-publishing tool alongside the existing conversation text.
  • head (f055cb28d7eb): The summarising model is offered a changed publishing-tool definition, with the conversation text and other illustrated settings unchanged.
  • Also in: Resident requests and requests to summarise conversation carry this definition change wherever the publishing tool is offered.

Delta 2: Message bodies differ without a listed history change (the description doesn't mention it)

In the thinking-enabled conversation dominated by reading-sized tool results, illustrated when a new input follows a completed tool exchange.

  • base (main): The model receives the preceding replies and tool results followed by the new input, with the base's transmitted message body.
  • head (f055cb28d7eb): The model receives the same stored exchanges and input at the same listed cache positions, but the transmitted message body differs.

Delta 3: Summarising instructions and recalled-summary labels change numbers (the description doesn't mention it)

In the tool-result-heavy conversations and the default conversations with lifecycle changes or pinned history under budget cuts, illustrated while summarising a pasted document after tool exchanges.

  • base (main): The summarising model receives conversation excerpts and summaries covering earlier history, with the base's numbers in the accompanying instructions and summary labels.
  • head (f055cb28d7eb): The summarising model receives excerpts and summaries covering the same history, but numbers in the accompanying instructions and summary labels differ.

Delta 4: Summarisation and storage move later (the description doesn't mention it)

In the tool-result-heavy conversations and the default conversation whose summary merges are refused, illustrated late in the tool-result-heavy conversation while an older document is being summarised.

  • base (main): The run asks for a summary of the older document slice and stores it at this point.
  • head (f055cb28d7eb): The run does not ask for or store that summary until after the following exchange.

Delta 5: History detail, overlap and cache boundaries change (the description doesn't mention it)

In the ordinary and tool-result-heavy conversations that adjust retained detail to context pressure, the default conversation with refused summary merges, and C6h/W9, which changes pins, restarts, forks and starts a new topic, illustrated there after tool exchanges following the forked subagent's reply.

  • base (main): The model receives the pinned opening exchange directly and inside overlapping summaries, with the inherited ending summarised and part of the preceding document sent directly, with cache markers at the base's selected boundaries.
  • head (f055cb28d7eb): The model receives the pinned exchange directly with only its closing reply still also summarised, the inherited ending directly with its input altered, and the document's ending through a summary, with cache markers moved.
  • Also in: Requests continuing the affected tool exchanges in these conversations carry the changed history forward with otherwise matching new replies and tool results.

Delta 6: Stored summaries change wording (the description doesn't mention it)

In the tool-result-heavy conversations, illustrated while saving a summary of a notice that the previous reply was delivered and the opening of a pasted document in the thinking-enabled conversation.

  • base (main): The store writes the base's summary wording for the delivery notice and the beginning of the pasted document.
  • head (f055cb28d7eb): The store writes different summary wording for those same source messages.

Delta 7: Final history-selection change reports differ (the description doesn't mention it)

At the end of the tool-result-heavy conversations, illustrated by the final report for the default conversation. Follows from Delta 5.

  • base (main): The final report of history-selection changes includes older document slices and tool exchanges as well as the latest reply and input.
  • head (f055cb28d7eb): The final report includes only the latest reply, its delivery notice and the current input; the thinking-enabled conversation instead gains older-history changes.
Runs whose records differ: 25 of 27, with 7 changes
H1/continuation: 1 change

Channel-publishing tool definitions change

At 3 steps; the quotes are from step heads.

Before:

channel_close:064c8388 channel_publish:489a8faa think:90ff8920

After:

channel_close:064c8388 channel_publish:35208621 think:90ff8920
H3/continuation: 1 change

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

H1/W1: 2 changes

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

History detail, overlap and cache boundaries change

At 14 steps. Its before and after are quoted under C6h/W9, at step 405389.11.

H4/W1: 2 changes

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

History detail, overlap and cache boundaries change

At 4 steps. Its before and after are quoted under C6h/W9, at step 405389.11.

C3h/W2: 1 change

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

H1/W2: 6 changes

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

Summarising instructions and recalled-summary labels change numbers

At 17 steps; the quotes are from step 204810.15.

Before:

[T:call-context-manager-you-04340ee23663](105666,2000) -> summary, body ef93012e

After:

[T:call-context-manager-you-04340ee23663](105713,2000) -> summary, body a483a67f

Summarisation and storage move later

At 4 steps; the quotes are from step 204810.67.

Before:

wrote L1(204810.59.in+7-204810.59.in+10) [T:summary-l1-59e67aead37c]

After: none.

History detail, overlap and cache boundaries change

At 5 steps. Its before and after are quoted under C6h/W9, at step 405389.11.

Stored summaries change wording

At step templates. Its before and after are quoted under H1b/W2, at step 69720.53.

Final history-selection change reports differ

At step end.

Before:

receipt fixture #70 changes 204810.48.in/summary/1/70 204810.48.r1/summary/1/70 204810.48.call1/summary/1/70 204810.48.r2/summary/1/70 204810.48.r2+1/summary/1/70

After:

receipt fixture #70 changes 204810.69.r1/raw/0/70 204810.69.r1+1/raw/0/70 204810.70.in/raw/0/70
H1b/W2: 7 changes

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

Message bodies differ without a listed history change

At 17 steps; the quotes are from step 69720.2.

Before:

compile + 69720.1.r1-69720.1.r3 [T:user-delivered-plain-speech-17b308e66c2c] 69720.2.in ^ 4,11, body 43248693

After:

compile + 69720.1.r1-69720.1.r3 [T:user-delivered-plain-speech-17b308e66c2c] 69720.2.in ^ 4,11, body 01656b8d

Summarising instructions and recalled-summary labels change numbers

At 27 steps. Its before and after are quoted under H1/W2, at step 204810.15.

Summarisation and storage move later

At 2 steps. Its before and after are quoted under H1/W2, at step 204810.67.

History detail, overlap and cache boundaries change

At 60 steps. Its before and after are quoted under C6h/W9, at step 405389.11.

Stored summaries change wording

At 8 steps; the quotes are from step 69720.53.

Before:

wrote L1(69720.48.r2+1-69720.49.in+2) [T:summary-l1-1f26f409932c]

After:

wrote L1(69720.48.r2+1-69720.49.in+2) [T:summary-l1-5420ee174f1e]

Final history-selection change reports differ

At step end. Its before and after are quoted under H1/W2, at step end.

H3/W4: 1 change

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

C5h/W5: 1 change

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

C8h/W6: 1 change

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

C3h/W7/merges-refused: 1 change

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

H1/W7/merges-refused: 3 changes

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

Summarisation and storage move later

At 2 steps. Its before and after are quoted under H1/W2, at step 204810.67.

History detail, overlap and cache boundaries change

At 8 steps. Its before and after are quoted under C6h/W9, at step 405389.11.

H1/W7/merges-rejected: 1 change

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

H2/W7/merges-refused: 1 change

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

H1/W7/chunk-refused: 1 change

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

C3h/W8/unenforced: 1 change

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

C6h/W8/enforced: 1 change

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

H1/W8/enforced: 1 change

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

H4/W8/unenforced: 1 change

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

H3/W9: 1 change

Channel-publishing tool definitions change

At 4 steps. Its before and after are quoted under H1/continuation, at step heads.

C3h/W9: 1 change

Channel-publishing tool definitions change

At 4 steps. Its before and after are quoted under H1/continuation, at step heads.

C6h/W9: 2 changes

Channel-publishing tool definitions change

At 5 steps. Its before and after are quoted under H1/continuation, at step heads.

History detail, overlap and cache boundaries change

At 11 steps; the quotes are from step 405389.11.

Before:

[T:user-market-shadow-market-da23a76276ed]!shards L1(593484.69.r1-405389.1.call1) L1(593484.69.r1-405389.1.call1) 405389.1.in-405389.1.r2 L1(405389.1.r2-405389.2.call1) L1(405389.1.r2-405389.2.call1)

After:

L1(593484.69.in+19-593484.69.in+22) L1(593484.69.in+19-593484.69.in+22) 593484.69.r1 [T:user-delivered-plain-speech-17b308e66c2c] 593484.70.in!content 593484.70.r1 [T:user-delivered-plain-speech-17b308e66c2c] [T:user-clock-tuesday-#-#-#-16d854dc023a](2026,01,06,09,00) 405389.1.in-405389.1.r2 L1(405389.1.r2-405389.2.call1) L1(405389.1.r2-405389.2.call1)
H1/W9: 2 changes

Channel-publishing tool definitions change

At 5 steps. Its before and after are quoted under H1/continuation, at step heads.

Summarising instructions and recalled-summary labels change numbers

At 2 steps. Its before and after are quoted under H1/W2, at step 204810.15.

C6h/W11: 1 change

Channel-publishing tool definitions change

At 3 steps. Its before and after are quoted under H1/continuation, at step heads.

H1/W11: 2 changes

Channel-publishing tool definitions change

At 2 steps. Its before and after are quoted under H1/continuation, at step heads.

Summarising instructions and recalled-summary labels change numbers

At step 232330.13. Its before and after are quoted under H1/W2, at step 204810.15.

Checks' flags that differ (14)
  • C6h/W9 step 405389.11, check 4 flagged on the head 2 more times than on the base: more-than-once: 1 message(s) reach the request more than once: [[c:405389.1.r2]]

  • C6h/W9 step 405389.12, check 4 flagged on the head 2 more times than on the base: more-than-once: 1 message(s) reach the request more than once: [[c:405389.1.r2]]

  • C6h/W9 step 405389.11, check 11 flagged on the head 1 more time than on the base: fixture:request:beside-sources: 1 stored messages sent raw beside a summary of them: 405389.1.r2 raw beside L1(405389.1.r2-405389.2.call1)

  • C6h/W9 step 405389.12, check 11 flagged on the head 1 more time than on the base: fixture:request:beside-sources: 1 stored messages sent raw beside a summary of them: 405389.1.r2 raw beside L1(405389.1.r2-405389.2.call1)

  • C6h/W9 step 405389.11, check 4 flagged on the base 2 more times than on the head: more-than-once: 3 message(s) reach the request more than once: [[c:405389.1.in]] [[c:405389.1.r1]] [[c:405389.1.r2]]

  • C6h/W9 step 405389.12, check 4 flagged on the base 2 more times than on the head: more-than-once: 3 message(s) reach the request more than once: [[c:405389.1.in]] [[c:405389.1.r1]] [[c:405389.1.r2]]

  • C6h/W9 step 405389.11, check 11 flagged on the base 1 more time than on the head: fixture:request:beside-sources: 4 stored messages sent raw beside a summary of them: 405389.1.in raw beside L1(593484.69.r1-405389.1.call1); 405389.1.r1 raw beside L1(593484.69.r1-405389.1.call1); 405389.1.call1 raw beside L1(593484.69.r1-405389.1.call1); 405389.1.r2 raw beside L1(405389.1.r2-405389.2.call1)

  • C6h/W9 step 405389.11, check 11 flagged on the base 1 more time than on the head: fixture:request:pins: summary L1(593484.69.r1-405389.1.call1) at L1 covers 405389.1.in, inside pin pin(405389.1.in-405389.1.r2) allowing L0

  • C6h/W9 step 405389.12, check 11 flagged on the base 1 more time than on the head: fixture:request:beside-sources: 4 stored messages sent raw beside a summary of them: 405389.1.in raw beside L1(593484.69.r1-405389.1.call1); 405389.1.r1 raw beside L1(593484.69.r1-405389.1.call1); 405389.1.call1 raw beside L1(593484.69.r1-405389.1.call1); 405389.1.r2 raw beside L1(405389.1.r2-405389.2.call1)

  • C6h/W9 step 405389.12, check 11 flagged on the base 1 more time than on the head: fixture:request:pins: summary L1(593484.69.r1-405389.1.call1) at L1 covers 405389.1.in, inside pin pin(405389.1.in-405389.1.r2) allowing L0

Held the same in base and head: connectome-host 7278e9c58fd7, chronicle 0.5.1, context-manager 0.24.0, membrane 0.8.0.

@nissa-seru

Copy link
Copy Markdown
Contributor

#995 supersedes this pull request. It keeps the speech and calls of a turn that holds an operator's private message out of every channel the resident doesn't name. Against main at aa134e75fc it adds 187 lines to src, where this branch adds 931 against 074ac60be0, and it carries, adapted, the seven tests this branch adds to test/all-except-e2e.test.ts. @rufalupa666-netizen, the description of #995 says what it changes and what it leaves as it was.

Credits

Ezra-2423 made #995 and wrote this comment.

— Ezra-2423

nissa-seru pushed a commit to anima-research/context-view that referenced this pull request Nov 9, 2026
…ay out

Four kinds of change a view couldn't show, since no run reached their paths:

- Hcont/W13 sends to its turn's own channel with channel_publish and then
  speaks (step `sent`), so the send holds its words as drafts and the
  continuation's `[continue]` row takes its held form. Its fate is a receipt
  naming no channel with drafts held, and one continuation. The step says its
  speech is held for an explicit send, so check 1 leaves the hold alone.
- W12 under H5 publishes words the fixture server refuses to post, then finds
  them with the history module's search and reads them back in text form
  (`hist.fail`, `hist.read2`), where the extract marks the send `, error`.
  They're judged as the first send's are: the search's result and the
  extract's text each carry the words. The mark isn't checked, since the
  record shows it. Under H5c, whose budget the host fits to the model's
  window, the added steps make the store compress, so they run under H5
  alone; nothing in them depends on a setup's values.
- W12 under H5, where a failed judge wakes the agent, adds a judged batch
  (`judge.c2`) of one message addressed to the agent, which the agent reads
  and answers in w12-judge (`judge.read2`) before its judge fails
  (`judge.err2`). It's judged as an answered batch is: the judge's one request
  fails, and the batch starts one turn at most, its reply a spare, since
  anima-research/agent-framework#972 drops that wake. judge.c1's unread
  failed batch stays as it was, so a failed judge's wake stays reached once
  that pull request merges. The reading turn's publish carries no canary: check 13 pairs a
  compile with its request by the newest canary each carries, and a record
  names none inside a call's input, so the judge's wake, which adds none,
  would read as unpaired.
- Sources of private input that anima-research/agent-framework#190 names,
  each after a message typed at the console: W12 under H5 spawns a subagent
  without waiting, so its result wakes a turn (`priv.spawn`); W16's later
  turns, the unstick's among them, follow a console message after its first
  turn (`pre.con`); and W17 adds a sleep's end and an operator's nudge. Where
  each turn's speech goes shows in the record, and no check judges it.

CHECKS.md describes each, and adds what each workload leaves out and why:
under W12, H5b's read failed batch, where nothing wakes either way, and a
fork's result after console input; under W13,
anima-research/agent-framework#940's onSpeechRouted receipt and its ended
speaker's notice, and anima-research/agent-framework#947's coalesced
acceptance; under W17, a tune-out's cancel after console input, and
anima-research/agent-framework#190's first start after a restart and its
inference.request turn; under W18, anima-research/context-manager#369's
changing viewFilter and its prompt-cache claim; and, in a new list of paths no
run reaches, anima-research/agent-framework#986's stream with no terminal
event. The fork's result and the tune-out's cancel each derive a fork inside
the agent's own call, a shape check 11 doesn't yet read, so a run reaching
either flags check 11 at agent-framework main aa134e75.
nissa-seru pushed a commit to anima-research/context-view that referenced this pull request Nov 9, 2026
…ay out

Four kinds of change a view couldn't show, since no run reached their paths:

- Hcont/W13 sends to its turn's own channel with channel_publish and then
  speaks (step `sent`), so the send holds its words as drafts and the
  continuation's `[continue]` row takes its held form. Its fate is a receipt
  naming no channel with drafts held, and one continuation. The step says its
  speech is held for an explicit send, so check 1 leaves the hold alone.
- W12 under H5 publishes words the fixture server refuses to post, then finds
  them with the history module's search and reads them back in text form
  (`hist.fail`, `hist.read2`), where the extract marks the send `, error`.
  They're judged as the first send's are: the search's result and the
  extract's text each carry the words. The mark isn't checked, since the
  record shows it. Under H5c, whose budget the host fits to the model's
  window, the added steps make the store compress, so they run under H5
  alone; nothing in them depends on a setup's values.
- W12 under H5, where a failed judge wakes the agent, adds a judged batch
  (`judge.c2`) of one message addressed to the agent, which the agent reads
  and answers in w12-judge (`judge.read2`) before its judge fails
  (`judge.err2`). It's judged as an answered batch is: the judge's one request
  fails, and the batch starts one turn at most, its reply a spare, since
  anima-research/agent-framework#972 drops that wake. judge.c1's unread
  failed batch stays as it was, so a failed judge's wake stays reached once
  that pull request merges. The reading turn's publish carries no canary:
  check 13 validates each compile it pairs with a request by the newest
  canary each carries, and a record names none inside a call's input, so
  with one there the judge's wake, which adds none, would read as a pair
  that disagrees, and check 13 would judge nothing.
- Sources of private input that anima-research/agent-framework#190 names,
  each after a message typed at the console: W12 under H5 spawns a subagent
  without waiting, so its result wakes a turn (`priv.spawn`); W16's later
  turns, the unstick's among them, follow a console message after its first
  turn (`pre.con`); and W17 adds a sleep's end and an operator's nudge. Where
  each turn's speech goes shows in the record, and no check judges it.

CHECKS.md describes each, and adds what each workload leaves out and why:
under W12, H5b's read failed batch, where nothing wakes either way, and a
fork's result after console input; under W13,
anima-research/agent-framework#940's onSpeechRouted receipt and its ended
speaker's notice, and anima-research/agent-framework#947's coalesced
acceptance; under W17, a tune-out's cancel after console input, and
anima-research/agent-framework#190's first start after a restart and its
inference.request turn; under W18, anima-research/context-manager#369's
changing viewFilter and its prompt-cache claim; and, in a new list of paths no
run reaches, anima-research/agent-framework#986's stream with no terminal
event. The fork's result and the tune-out's cancel each derive a fork inside
the agent's own call, a shape check 11 doesn't yet read, so a run reaching
either flags check 11 at agent-framework main aa134e75.
nissa-seru pushed a commit to anima-research/context-view that referenced this pull request Nov 9, 2026
…during a private turn

anima-research/agent-framework#190 marks two kinds of agent as a private
turn's work: one spawned or derived during the turn, and an ephemeral run
handed mail from such work. W12 under H5 reaches the first's spawned
case, priv.spawn; its derived case, a fork, is the row above, which W12
leaves out. The second, a running subagent sent a message with the subagent--message
tool during a turn after console input, needs a subagent still running
when a later step's turn sends to it, a hold across steps the driver
doesn't have. Where either subagent's result lands, agent-framework marks
it through the same delivery (attemptMailDelivery), which priv.spawn
reaches, so the row says that and leaves the hold out for rent.
nissa-seru pushed a commit to anima-research/context-view that referenced this pull request Nov 9, 2026
…during a private turn

anima-research/agent-framework#190 marks two kinds of agent as a private
turn's work: one spawned or derived during the turn, and an ephemeral run
handed mail from such work. W12 under H5 reaches the first's spawned
case, priv.spawn; its derived case, a fork, is the row above, which W12
leaves out. The second, a running subagent sent a message with the
subagent--message tool during a turn after console input, needs a
subagent still running when a later step's turn sends to it, a hold
across steps the driver doesn't have. Where either subagent's result
lands, agent-framework marks it through the same delivery
(attemptMailDelivery), which priv.spawn reaches, so the row says that and
leaves the hold out for rent.
@nissa-seru nissa-seru closed this Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants