Skip to content

fix(mcpl): publish() publishes only into a registration confirmed open, under the grant as it is then - #569

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/publish-waits-for-new-registration
Oct 19, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:fix/publish-waits-for-new-registration

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

publish() now publishes only into a channel registration that is confirmed open when its last check runs, and under the grant as it is then. If the registration it finds there is unconfirmed, as a new registration or a retarget during its waits leaves it, the words wait for that registration's queued lifecycle work, and the check runs again. A close decided before that check now stands there, as it does when decided while the words wait, where the host directs the server's lifecycle. Until now that check refused only a registration that was gone or inbound-only, and published into any other, even one whose open hadn't been answered.

This extends the last check that #350 added.

Problem

publish() waits for a channel's queued lifecycle work and its open, announces an opening it made, and then checks the channel's registration once more just before it publishes (#350). A server's channels/changed can change the channel in between, for example during the opening's notice, in two ways that leave the registration unconfirmed:

  • A new registration of the channel. registerEntry makes a fresh entry with open: false, and the new registration's reconcile queues the open its decided state asks for.
  • A retarget. The entry stays, but it is marked unconfirmed (open: false) and an open of the new target is queued.

The last check found neither gone nor inbound-only, so the words went out before anything had confirmed the registration open. That goes against publish()'s stated invariant that nothing is sent into a closed channel. Runs on main before this change show both cases: with the new open held, the words were published while it was in flight.

A close decided in the same window, for example by the agent during the notice, was passed over too, though waitForOpenChannel refuses words whose channel is decided closed while they wait.

A decided close also read two ways on a server without channels.lifecycle. The open step (engagesDecidedClosed) passes over it there: the host can't close the channel, so words go into its open transport, as the existing test "speech into a channel decided closed keeps publishing where no open can succeed, without the lifecycle grant" pins. waitForOpenChannel refused it whatever the grant. The two differ only on a server without the grant that still has lifecycle work queued, as after the grant is withdrawn while the words wait.

The same check didn't read the grant. channels.publish was checked once, before the waits, so a grant reduced during them, or during the notice, still let the words out. In the MCPL spec's §6.7, a security-reducing change "takes effect atomically first", since "reducing late leaves a window of over-permission".

Changes

  • The loop in the last check. It runs while the registration in the map isn't confirmed open, or its close stands, and it isn't inbound-only. The words wait for its queued lifecycle work with waitForOpenChannel, as at the first wait, which refuses what that work leaves unconfirmed or with its close standing. The check then runs again on the registration that stands. The loop opens nothing itself. A failure there reads as at the first wait, "lifecycle work prevented delivery: …".
  • One reading of a decided close. A helper, closeStands(), says a decided close holds words back only where the host directs the server's lifecycle, as engagesDecidedClosed already reads it. The loop and waitForOpenChannel's refusal both use it. On a server without the grant, a decided close no longer refuses words that waited for queued lifecycle work.
  • The grant. The last check reads the server's grant as it is then, and refuses a grant without channels.publish in the words the check before the waits uses.
  • Two more helpers build the lifecycle failure and the grant refusal, each used in two places.
  • Texts. publish()'s and waitForOpenChannel's docs say so, and the changelog fragment changelog.d/publish-waits-for-new-registration.fixed.md describes the three changes and the two effects below.

What each part is for

  • The loop. It is the fix: the words go only into a registration confirmed open, by a lifecycle receipt or, on a server that opens its own channels, by its traffic. Otherwise they fail visibly.
    • It has no bound of its own. Each further pass needs the channel to change again after a wait, and waitForOpenChannel already waits for as long as lifecycle work keeps coming.
    • It ends. waitForOpenChannel returns only a registration it found open and whose close doesn't stand, or throws.
  • Its condition on confirmation. A new registration starts with open: false, and a retarget is marked unconfirmed, so open catches both, whichever registration the map holds.
  • Its condition on a close that stands. A close decided after the last wait leaves the entry open until its close runs, so open alone would miss it.
  • closeStands()'s limit to servers whose lifecycle the host directs, shared with waitForOpenChannel. Without that grant the host can't close the channel. With one helper, a decided close can't hold the words back at one wait and let them through at another.
  • Its re-read of the map after each wait. It sees the registration that stands. With a stale one, the loop would keep checking a registration already replaced, and never end.
  • Skipping the wait for an inbound-only registration. Nothing would be published into it, so the refusal names the declaration at once, rather than whatever its open does.
  • The grant check after the waits. A reduction holds for words already on their way.

Two more effects follow from the loop, both refusals the invariant asks for:

  • A registration no longer marked open, with nothing queued, is now refused, where it used to be published into. That happens, for example, when a server answers an open of it for another channel. A failed open of a channel already confirmed open for its current target keeps it marked open, on purpose, and publishing into it is unchanged.
  • A retarget on a server without channels.lifecycle is refused, as the host's own open would be there. If the server's traffic has confirmed the retarget open by then, the words go out.

Tests

In test/channel-lifecycle-order.test.ts, each new case starts by changing x, or its server's grant, during its opening's notice:

  • Registered again, its open held. Nothing is published while that open is in flight. When it's answered, the words go out after it. When it fails, nothing goes out, and the route failure says lifecycle work prevented delivery.
  • Retargeted, its open held. Nothing is published until the new target's open is answered.
  • Registered again, then again while the words wait. The words wait through the newest registration's lifecycle work, and are delivered once.
  • Registered again as inbound-only. The words are refused for the declaration at once, while its open is still in flight.
  • The grant loses channels.publish, during the notice, or while the words wait for a new registration's open. The words are refused in the grant's words. The first case also checks the outcome's destination.
  • The agent closes x during the notice. The words wait for the close and are refused, and the channel ends closed.
  • A retarget with nothing queued, after the grant loses channels.lifecycle. The words are refused, and no open is made for the new target.
  • Registered again and confirmed open by its traffic, after the grant loses channels.lifecycle and the agent closes x. The words go out into the open transport.
  • Registered again, then the grant loses channels.lifecycle and the agent closes x while the words wait. The close is refused for the grant, the new registration's open is answered, and the words go out.
  • The draft refused for a pending close now also checks the failed outcome's destination.

At 2a8fcd6a (code and tests as at a2314558) on Linux, with Node 24.22.0, tsc --noEmit and the build are clean, and the whole suite passes: 4988 tests, 4961 pass and 27 skipped, none failing. channel-lifecycle-order runs 86 of 86.

With main's source (26653d36) and this change's tests, eight of the new cases fail:

  • the two for a new registration, the retarget and the second registration, each at its "nothing is published …" check;
  • the agent's close during the notice, at "nothing is published while the close is queued";
  • the two for the grant and the retarget with nothing queued, each with the words delivered where the test expects a failure.

The other new cases pass there, since nothing at the last check refused words on main: the inbound-only case, the two that end in the words going out once the grant loses channels.lifecycle, and the destination check.

Each part, removed on its own, fails a test:

  • the loop;
  • the skip for an inbound-only registration;
  • the condition on confirmation;
  • the condition on a close that stands;
  • closeStands()'s limit to servers whose lifecycle the host directs, which also fails the existing "speech into a channel decided closed keeps publishing where no open can succeed, without the lifecycle grant";
  • that limit in waitForOpenChannel alone;
  • the grant check after the waits;
  • each helper's destination.

Without the loop's re-read of the map, the loop never gives the event loop a turn: the test runner's own 60 s timeout never fires, and the removal run stops the file at its 300 s limit.

Not verified

  • A registration no longer marked open, with nothing queued, is refused (the first effect above). That rests on reading the substitution path, and no test makes that case on its own.
  • A retarget the server's traffic confirms open goes out on a server without channels.lifecycle (the second effect). That rests on reading markAccepted. The test for a channel registered again and confirmed open by its traffic runs the same path for a new registration.

  • Changelog fragment added: changelog.d/publish-waits-for-new-registration.fixed.md

Written by Cordelia-2434 (Claude Opus 5.5), contributing through nissa-seru, to design calls by Ada-1017 (Claude Opus 5.5). Desmond-2579 (Claude Opus 5.5) read the change before it opened, and our internal review read it.

Description checked and posted by Daniel-2612 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

@greptile-apps

greptile-apps Bot commented Oct 19, 2026

Copy link
Copy Markdown

Required label not found on this PR.

…n, under the grant as it is then

publish() checks the channel's registration once more just before it
publishes, after every await: the lifecycle work's, the open's and the
opening's notice. That check refused a registration that was gone or
inbound-only, and sent into any other. A server's channels/changed can
change the channel in that window, for example during the opening's notice,
in two ways that leave the registration unconfirmed: a new registration of
the channel makes a fresh entry with open: false, and a retarget keeps the
entry but marks it unconfirmed (open: false). Either way the reconcile
queues an open that nothing has answered yet, so the words went out against
the invariant publish() states, that nothing is sent into a closed channel.
A close decided in that window was passed over too, though
waitForOpenChannel refuses words whose channel is decided closed while they
wait. And the check didn't read the grant: channels.publish was checked
once, before the waits, so a grant reduced during them still let the words
out.

Now the loop in the last check runs while the registration in the map isn't
confirmed open, or its close stands, and it isn't inbound-only. The words
wait for its queued lifecycle work with waitForOpenChannel, as at the first
wait, which refuses what that work leaves unconfirmed or with its close
standing, and the check runs again on the registration that stands. The
loop opens nothing itself. waitForOpenChannel returns only a registration
it found open and whose close doesn't stand, or throws, so the loop ends
unless the channel keeps changing. A failure there reads as at the first
wait, "lifecycle work prevented delivery: ...". The loop has no bound of
its own: each further pass needs the channel to change again after a wait,
and waitForOpenChannel already waits for as long as lifecycle work keeps
coming. An inbound-only registration is refused at once, without waiting
for its open. Then the check reads the grant as it is, and refuses one
without channels.publish in the words the check before the waits uses, so
a reduction holds for words already on their way (SPEC 6.7). One helper
builds each of the two refusals for both places it's made.

A decided close read two ways on a server without channels.lifecycle: the
open step's engagesDecidedClosed passes over it there, since the host can't
close the channel and words go into its open transport, while
waitForOpenChannel refused it whatever the grant. One helper, closeStands(),
now reads it for the loop and for waitForOpenChannel alike: a decided close
holds words back only where the host directs the server's lifecycle. That
changes waitForOpenChannel only for a server without the grant that still
has lifecycle work queued, where a decided close no longer refuses words
into an open transport.

Tests: new cases in channel-lifecycle-order start by changing x, or its
server's grant, during its opening's notice. When x is registered again
with the new open held, nothing is published while that open is in flight;
when it's answered, the words go out after it, and when it fails, nothing
goes out and the route failure says lifecycle work prevented delivery. When
x is retargeted, nothing is published until the new target's open is
answered, and a retarget with nothing queued for it, once the grant has
lost channels.lifecycle, is refused. When x is registered again, and again
while the words wait, they wait through the newest registration's lifecycle
work and are delivered once. When the agent closes x during the notice, the
words wait for the close and are refused. When x is registered again as
inbound-only, the words are refused for the declaration at once. When the
grant loses channels.publish, during the notice or while the words wait for
a new registration's open, the words are refused in the grant's words, and
the first case checks the outcome's destination. Once the grant loses
channels.lifecycle and the agent closes x, a new registration confirmed
open by its traffic takes the words, and so does one whose open is answered
while the words wait. The draft refused for a pending close also checks the
failed outcome's destination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru force-pushed the fix/publish-waits-for-new-registration branch from a231455 to 2a8fcd6 Compare October 19, 2026 10:57
@nissa-seru
nissa-seru merged commit 10917f7 into anima-research:main Oct 19, 2026
6 checks passed
@nissa-seru
nissa-seru deleted the fix/publish-waits-for-new-registration branch October 19, 2026 11:50
tel-0s added a commit that referenced this pull request Oct 19, 2026
…ge entry for bounded jobs, the batched-wake hold

What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round
speech), #250 (surgery marks opt-in), #463 (the workspace edit planner),
#168 (non-conversational marker tags: a reaction, edit or deletion never
retargets the publish locus, apart from an edit the coalescer marks as the
agent's first view), #558 (every ephemeral agent's usage in one shared
entry), #570 (the batched-wake tests hold the gate's batch), #569, #565,
#566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs.

Conflicts in five files, resolved by composition:

- src/agent.ts: the request strips the framework's token-estimate stamp
  (main) and then presents the agent's own turns under its name (ours).
- src/framework.ts: `ephemeralCandidates` keeps this branch's record
  (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and
  createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only
  (main's second argument is unread) with main's `readsDeferredWrite`
  spread; the re-pin rule's doc is main's with the `speech.locus` sentence;
  the turn-start flush is main's `flushForStart`, which gains this branch's
  `releaseLandedMail` so held mail whose message lands is released.
- src/mcpl/channel-registry.ts: the three Dendrite options
  (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside
  main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but
  records no publish locus — admission does, through `noteDelivered`, and
  the framework's three `noteDelivered` sites now apply main's marker rule
  (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is
  never the latest delivered inbound either; `channel_publish`'s default
  comment says the same.
- test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps
  its shape and gains main's `holdBatches`, reading the rig's framework;
  every test that pairs two sends holds the batch, the two ambient-only
  tests included.
- test/present-while-acting.test.ts: `createFramework` takes `speech`,
  `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests
  (traces and typing after a re-pin, the budget restart, the inbound-only
  arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs
  to, beside this branch's default-rule test.

Three seams the suite found, all at the registry's reply anchor, which main
sets where it accepts a message and this branch sets where admission
delivers one:

- #168's marker rule now lives in `noteDelivered` (tags and the coalescer's
  first-view mark are passed by the framework's three delivery sites), so
  the registry's unit tests exercise it as the framework calls it.
- `buildChannelContext` reported no `incoming` when the speech fallback was
  null, and no context at all when nothing was open and no outgoing channel
  resolved — under `speech.locus: 'delivery'` that hid the reply anchor
  whenever the latest inbound wasn't a speech fallback. The anchor now
  depends only on `takesGlobalFallback` (an ephemeral run has none), and a
  context with an anchor is returned whatever else it lacks.
- A coalesced acceptance's anchor is committed when the acceptance is
  acknowledged, not when the coalescer delivers: an acceptance whose commit
  fails after its delivery moves no anchor, and the retry the receipt
  answers commits the one the first attempt parked (`parkedAnchors`, keyed
  by server and event id, bounded). #157's tests pinned both halves.

Test adaptations beyond the conflicts: #157's two re-pin tests and
outgoing-stream's re-pin test run under the legacy locus rule, as the others
do; two compiled tests main deleted (`mcpl-awareness-barrier`,
`prose-stream-router`) were still in `dist/test` and failing from there —
removed, since tsc doesn't clean outputs whose sources are gone.

Two lines git doesn't show, named by nissa-seru's review of #546:

- #558 reads `config.subconscious.name` for the shared usage entry's
  exemptions; a forks reader has no agent of its own, so the name is read
  only when `reader !== 'forks'` (the union has none otherwise).
- resumeAgent makes its agent with `new Agent`, so it adds the agent to
  `ephemeralAgents` itself: a resumed job's usage counts in the shared
  entry, not under its own name.

Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 24, 2026
…s rule on main's channel machinery

main moved 1,643 commits since anima-research#330's last merge (0756302). Routing
became ChannelRef-based (anima-research#710), every lifecycle operation of a channel
now goes through one queue (anima-research#210), publish() refuses words for a close
that stands (anima-research#569), a failed open by speech puts back the decision it
replaced (anima-research#568), and the channel texts were reworded (anima-research#589, anima-research#675). Six
files conflicted, in 45 hunks. The resolution starts from main's side
of each of them and puts anima-research#330's rule back on main's machinery, so the
diff against main is anima-research#330's rule and nothing anima-research#330 used to carry that
main now does.

The rule is anima-research#330's, unchanged: speech never undoes the agent's own
close or decline, or a module's close; it opens every other closed
channel, as before.

Where it now lives:
- speechLeavesAsItStands(entry) decides, on main's reading of what needs
  an open (a channel that isn't open, or whose close stands): words go
  into such a channel as it stands where the decision on record is the
  agent's close or decline or a module's close, whatever its transport,
  or where its server is up and this host doesn't direct its lifecycle.
  speechOpens(channel) is every other channel that needs an open. A
  tuned-out channel's transport is reconfirmed as on main, and its
  tune-out stays. A server that isn't up isn't counted as one this host
  can't direct, though a drop empties its grant until its next exchange:
  speech there is refused as on main, by the open's failure or, where
  the grant is empty, by publish()'s grant check.
- publish() asks it at its first wait, its open step and its last check.
  Where speech opens the channel, main's path is unchanged, failure texts
  included: a failed open sends nothing. A wait or an open that fails
  where the channel is by then one speech leaves as it stands, as when
  the agent closed or declined it while the words waited, gives way, and
  the words go in. Two changes from main follow, both named for the
  release: anima-research#569's last check no longer refuses words for a channel the
  agent or a module closed itself (a module's close was recorded as the
  agent's on main), and a closed channel on a server that's up but whose
  grant lacks channels.lifecycle gets the words, where main failed them.
- openIfClosedForSend() stays, and asks the same after main's own
  already-open check: a channel a send leaves as it stands, found so or
  become so while its wait or open was out, answers a new status,
  stays-closed, or already-open where its transport is open with no close
  standing, and opens nothing. Every other channel opens as on main, one
  open at the transport with no decision on record included, so the
  send's engagement is still recorded as an open decision there. main's
  open-failed notice, opening and selectedForOpening are as on main.
  resolveSendTarget() is the synchronous lookup the send's note needs
  before its answer is queued (sendTargets, main's lookup).
- setDesiredState keeps main's rule (the agent's decision records itself
  over a record that isn't the agent's) and adds anima-research#330's: module-tool
  counts as a deliberate close, the latest deliberate close records
  itself over a different one (a decline after a close, and the reverse),
  and a machine's close of a channel with no record writes default-closed
  (anima-research#417).
- channel_decline is refused only for a channel open at the transport.
  Its close goes through main's queue wherever the transport may be open;
  anima-research#330's own channels/close call is gone.
- The note, the explicit-mode line (its >> deliveries are now recorded
  for it), the [tuned out] line, the script notice, closed_channel_note,
  the invitation's option 1 and its chat:dm skip, the sleep words,
  channel_close's description, and the opened notices' last sentence are
  anima-research#330's texts on main's code. The [tuned out] line now tells the agent
  main's behaviour: a send has kept a tune-out since 0.23.0. The send's
  note is appended to the answer's text or blocks, and a failed lookup
  leaves the answer as it was.

Dropped, since main does them: the optional serverId on isChannelOpen
and getDescriptor and entryFor (main's ChannelRef reads), canOpenChannels
(directsLifecycle), openForSpeech, R1-4's publish failed (main's
publishErrorOutcome), the flag-follows-decision lines in openChannelNow
and reconciliation (main's queue settles that race), "the words go out
whatever the open does" (its reason, a server that can't open channels,
is now left as it stands before any open), and the engagement record
anima-research#330 moved before the send's answer (main records it, by ChannelRef).

Tests: anima-research#330's send-into-closed-channel, registry, invitation and
explicit-mode tests are adapted to main's API, with new ones for the
words that wait while the agent closes or declines, a close still on
its way, a failed close, and the send's statuses. Tests of main's that
pinned the old behaviour are turned round, or moved to a machine's
close, which still gets main's refusals:
- channel-lifecycle-order: the draft and speech waits on a close, the
  close during an opening's notice (with the agent's close beside it),
  a retarget and a closed transport on a server without
  channels.lifecycle, a fresh delivery, a backscroll join, the retarget
  joins, a reply's put-back, speech's put-back and its lift, the
  failed-close engagement (now both closers), the replies on a server
  without the grant, the queued-close reply, a reply's open overtaken by
  the agent's close, and the close over a decline;
- channel-agent-words: speech on a server that's up without
  channels.lifecycle (now delivered; connecting still fails as on main),
  its close during later work (a machine's now), and the decline
  refusal's words, which shared-channel-id pins too;
- inbound-source: its decorated push is now a mention from a channel the
  agent closed, since a DM carries no invitation;
- the opened notices' last sentence in trunk-channel-routing,
  route-failure-targeting and channel-lifecycle-order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant