Skip to content

Bind automatic replies to admitted work (stacked on #906) - #931

Open
theaspirational wants to merge 61 commits into
anima-research:mainfrom
theaspirational:reply-destination-ownership
Open

theaspirational wants to merge 61 commits into
anima-research:mainfrom
theaspirational:reply-destination-ownership

Conversation

@theaspirational

@theaspirational theaspirational commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #906 (required inbound admission). This branch carries #906's commits, so the #931-specific diff is 0fea3b4f..f7f24e6a.

Problem

An automatic reply could go to a destination the turn was not admitted for. Reply ownership could outlive the turn that held it: after an exhausted turn re-armed, the ended owner was kept. Destination defaults could also be inferred for private work, and mixed debounce batches combined bound and unbound causes.

Changes

  • Adds an optional, separate post-admission consumer and frozen automatic-reply authority. Automatic replies are bound to the admitted destination.
  • A re-armed turn after exhaustion gets fresh ownership. The ended owner is removed, and its retained work is kept.
  • Private work has no inferred trigger, locus or omitted publish-tool default.
  • Compatibility is rechecked after late hold and batch expansion.
  • The resolver deadline uses monotonic elapsed time. A late synchronous result stays private.
  • Timeout and overload diagnostics are bounded and throttled.
  • Documentation separates reply authority from admission.
  • Rebased onto Add opt-in required admission for inbound MCPL traffic #906 after its round 4 (0fea3b4, based on 074ac60); the reply consumer takes Add opt-in required admission for inbound MCPL traffic #906's private response continuation, with no public envelope fields. The rebase keeps Add opt-in required admission for inbound MCPL traffic #906's guarantees: receipt replay at the ticket cap, withdrawals never refused at the module cap, the config-identity capability (token redaction survives an enabled reply authority; no config wrapper), and module dispatch only after the lane acknowledgement. The reply authority's callbacks and deadline are snapshotted before the handshake and consumed at wiring.

Limits

  • A debounce batch that mixes causes bound to a destination with unbound causes keeps its wake and full context, but no automatic reply is published for it. Agents can still reply with explicit send tools.
  • Explicit >>/>>> envelopes stay fail-closed under the binding guard. To send elsewhere, use a separately authorized send tool. The docs now say this.
  • mayWake: false still allows the later tune-out reader cadence.
  • No new recovery or transport contract. Synchronous host callbacks cannot be preempted.
  • Unconfigured hosts, without reply routing, are unchanged.

Tests

  • Typecheck passes.
  • Targeted reply-routing, admission, backlog and ordering tests: 1,319 pass.
  • Full suite (concurrency 4): 10,094 pass / 0 fail / 63 skip. The Add opt-in required admission for inbound MCPL traffic #906 head (0fea3b4) on the same setup gives 9,902 / 0 / 63.
  • Both-lane WebSocket tests combine frozen authority, inherited readers, config identity and token redaction, acknowledgement before module work, bound publication and effect-free replay.
  • Mutations of the rebase's conflict resolutions (private routing in the admitted envelope, response-boundary release) are killed, from the implementer and independently.
  • Tests use loopback/stdio transports only. No live provider or connector was exercised.

AI-assisted implementation with GPT-6.1-Sol

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.

Reviewed commit 20610f0 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits, with concerns. Reply routing extends the project's MCPL channel-routing work as an opt-in feature, and it is declared as stacked on #906 the way CONTRIBUTING.md asks. A maintainer still needs to decide whether to accept a non-standard experimental.replyRouting wire profile and a general-purpose initializeExtensions host API.

6 reasons with sources
  • The feature fits the project's existing goal of sending automatic speech to the right channel and never to the wrong one. explicit-prose-routing.md explains that it exists because prose went to a channel the host guessed, and it chooses to drop a reply rather than misdeliver it. This PR binds automatic replies to a destination fixed when the message is admitted, and refuses to send when that binding is missing. That is the same goal. The README lists publishing to channels as part of MCPL's role. (docs/explicit-prose-routing.md; README.md (MCPL section))
  • It adds an MCPL wire profile that the PR itself calls local and non-standard, without a SPEC companion. Maintainers should decide whether to accept it or require registered thread-channels. The existing experimental capability, experimental.mcpl.toolHints, is backed by an RFC. The new experimental.replyRouting v1 adds an optional threadId to channels/publish, and a peer that doesn't negotiate it is refused at initialization. docs/reply-routing.md says maintainer agreement on the field names is still outstanding. The PR asks this question itself. Threadless destinations and thread-as-channel destinations don't need the extension. (README.md (Tool presence and toolsets (RFC-014)); CONTRIBUTING.md (How changes land))
  • initializeExtensions adds a second public host API that the routing feature doesn't need, and the project would have to maintain it. These are data-only offers that hosts can put under capabilities.experimental. The docs say they grant no authority. This is a general hook for advertising capabilities, separate from reply routing, and no project document asks for one. It could be proposed on its own. (src/mcpl/types.ts; docs/reply-routing.md (Thread transport))
  • The code sits in the right places: an opt-in config on McplServerConfig, with a new module in src/mcpl called after the admission verdict. src/mcpl/reply-routing.ts and the post-admission consumer in inbound-admission.ts sit next to the existing channel registry, push handler and server-connection code. Connections that don't opt in keep their current routing, and the docs say the new path adds no connector-name selection. (README.md (MCPL); src/mcpl/ layout)
  • The PR follows the contribution rules: it declares the stack, gives test counts and lists what wasn't verified, and adds a changelog fragment and docs. The PR gives merge order (#906 first), the exact commit range that belongs to this PR alone, pass/fail counts against the #906 baseline, a Not verified section and changelog.d/admitted-reply-routing.added.md. Because it is stacked, the diff also carries #906's admission work. That is allowed, but #906 has to be accepted first. (CONTRIBUTING.md (How changes land; What a PR should contain))
  • The resolver also runs on inference-request and push traffic, which reaches past the 'automatic replies' scope that the description states. The docs describe the resolver as running after any admission verdict that stores the message and wakes the agent. They don't say an inference request will be refused unless the host returns a channel for it. The code review flagged this. A maintainer should confirm whether those lanes belong in the scope. (docs/reply-routing.md)

Findings

5 findings of medium or high severity, see the inline comments:

6 more findings, less severe
  • Low severity, maintainability, src/mcpl/reply-routing.ts:66-81. routingData duplicates inbound-admission's snapshot helper. routingData is a near line-for-line copy of the private snapshot function in src/mcpl/inbound-admission.ts (lines 42-58). Both are data-only detachment that refuses accessors, custom prototypes and cycles. The only difference is that it throws a plain Error instead of InvalidData and rejects undefined. Two copies of a security-relevant sanitizer will drift. One shared, exported helper should be used.
  • Low severity, maintainability, src/mcpl/server-connection.ts:1480-1504. Handshake resolve block is duplicated, with broken indentation, for the attestation path. Under if (admission), the code that extracts caps/experimental and calls resolve({...}) is pasted inside the attest().then callback, at the outer indentation level. The identical block follows for the unattested path. Any later change to how capabilities are derived must be made twice. The misindentation hides that the first copy runs asynchronously. A shared local function should be used.
  • Low severity, maintainability, src/framework.ts:35004-35012. Three hand-copied RequiredIngress wrappers in wireMcplEvents. The push/event (around 35008), inference/request (around 35082) and channels/incoming (around 35199) handlers each rebuild the same wrapper: take connection.admissionFor(params), then override check with queue-closed / registry-identity / original checks. The only difference is an extra pushCoalescer.isRendering condition on one lane. If a staleness rule changes in one copy and not the others, the lanes will give inconsistent admission results. A single helper that takes the extra predicate would avoid this.
  • Low severity, maintainability, src/framework.ts:18827. Name clash with existing framework AdmittedEnvelope forces inline import() casts. framework.ts already declares an unrelated interface AdmittedEnvelope (line 1197, used for prose text/resolution). The new admission envelope type from inbound-admission.ts has the same name. The change therefore uses inline import('./mcpl/inbound-admission.js').AdmittedEnvelope casts at 18827 and 18931, plus as unknown as casts. Two different meanings under one name in the same file will confuse later readers. One of them should be renamed, or an aliased import used.
  • Low severity, test coverage, test/mcpl-post-admission.test.ts:20-35. "only for allow" test never covers a store-true, read-only verdict. RequiredAdmission.capture calls the post-admission consumer whenever verdict.store is true, including mayWake: false (inbound-admission.ts line 139). The read-only short-circuit lives only inside replyConsumer. The test cases are allow, deny and malformed, so the read-only case { store: true, mayWake: false } is never exercised at this seam. The test name implies the seam itself filters to wake-able verdicts, and a regression in replyConsumer's !verdict.mayWake guard would not be caught here. A read-only case should be added, with its expected call count stated.
  • Low severity, maintainability, src/framework.ts:17034. tool_results_ready owner lookup is inconsistent with the budget-restart lookup. The budget restart at line 16917 uses replyStreamOwners.get(stream) ?? openLogical.get(agent). The non-streaming tool continuation at 17034 uses openLogical only when there is no stream at all. If the stream is not in replyStreamOwners, the tool continuation gets no replyOwner. While the bound turn is open, replyStartDisposition would then defer it indefinitely. Both continuation sites should resolve the owner the same way.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/mcpl/reply-routing.ts Outdated
Comment on lines +67 to +69
export function routingData(value: unknown, seen = new Set<object>()): any {
if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return value;
if (!value || typeof value !== 'object' || seen.has(value)) throw Error('invalid_reply_routing');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. Resolver result with threadId: undefined is refused as admission_invalid.

ReplyRoutingConfig.resolve is typed to return { channelId: string; threadId?: string }. The project does not enable exactOptionalPropertyTypes (no tsconfig in the repo sets it), so { channelId, threadId: maybeThread } with maybeThread === undefined type-checks. routingData accepts only null, string, boolean, finite number or plain object. An own property whose value is undefined reaches !value on line 69 and throws, so replyConsumer returns admission_invalid. A threadless message is then refused entirely, even though the validation on lines 92-95 is written to accept an absent threadId. undefined values should be treated as absent, or explicitly allowed for threadId.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Superseded by f0ce372. The replacement replyAuthority API accepts one registered channel destination and has no threadId field. Missing or invalid reply authority keeps the admitted work private instead of refusing admission.

Comment on lines +2606 to +2613
if (this.requiredAdmission && ['channels-incoming', 'push-event', 'inference-request'].includes(eventName) && request.params && typeof request.params === 'object') {
const cap = McplServerConnection.EVENT_TO_REQUIRED_CAPABILITY[eventName]!;
this.admissionReceipts.set(request.params, this.requiredAdmission.capture(() => {
if (this.transportEpoch !== epoch || !this.isConnected) return 'admission_stale';
if (!this.grant.has(cap)) return 'admission_stale';
if (!this.controlPlaneReady || !this.dataPlaneReady) return 'admission_not_ready';
}, this.requiresReplyBinding ? this.postAdmissionConsumer ?? (() => ({ reason: 'admission_missing' as const })) : undefined));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. replyRouting resolver runs on inference/request and push lanes, so an inference request is refused unless the host invents a channel.

The post-admission consumer is attached to every admitted lane: channels-incoming, push-event and inference-request. It is used whenever requiresReplyBinding is set. For inference/request, replyConsumer calls resolve. If the result is null, it returns admission_invalid. If the result is not a registered channel, it returns admission_stale. InferenceRouter.answer then rejects the request with -32600. On success the binding is only checked and then thrown away, because inference/request answers the peer directly and never makes an automatic channel reply. Once a connection opts into replyRouting, every server-initiated inference request fails unless the host resolver returns some registered publish channel for it. The docs do not mention this. The consumer should apply only to lanes that can produce an automatic reply, or a null result on inference/request should admit without a binding.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 428cbd9. inference/request bypasses the reply consumer and keeps its direct JSON-RPC answer path. Real-stdio cases cover both live and held requests, asserting no resolver call or channel publish.

Comment thread src/framework.ts Outdated
Comment on lines +19590 to +19602
if (this.openLogical?.get(agentName)?.reply || requests.some(r => workOf(r).length)) {
const runnable: InferenceRequest[] = [];
for (const request of requests) {
const disposition = this.replyStartDisposition(agentName, request);
if (disposition === 'defer') this.pendingRequests.push(request);
else if (disposition === 'stale') this.emitTrace({ type: 'inference:request_dropped', agentName, reason: 'reply_owner_ended', requestCount: 1 });
else runnable.push(request);
}
const first = runnable[0];
if (!first) {
if (!this.pendingRequests.some(r => r.agentName === agentName)) this.endPendingPrimary(agentName);
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity. Reply-deferred requests bypass the scheduler's stale/wedge diagnostics.

The new reply block requeues every request whose disposition is defer into pendingRequests and then continues. This happens before the busy-agent branch (around line 19766), which emits inference:request_stale and the [inference-stale] stderr line after 30s. Any wake that waits behind an open bound turn, including one stuck on a leaked or never-closing bound turn, is now requeued forever with no trace or log. The existing comment calls that diagnostic "the wedge's tell". Deferred requests should go through the same stale-age reporting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 46fbee0. Reply-deferred waits now use the shared stale-request diagnostic with preserved age, the existing throttle and quiesce suppression. The scheduler regression covers an old deferred wake alongside a runnable continuation.

Comment thread docs/reply-routing.md Outdated
Comment on lines +79 to +98
Automatic prose and sleep announcements must match the frozen channel and
thread. Re-pinning, channel-open tools and all-except policy cannot change the
current bound turn's destination. Registration, connection incarnation,
current grant and thread support are checked again before open/send effects.
A disconnected or replaced connection invalidates old authority; no automatic
cross-epoch reauthorization or replay is provided. Failed/unknown publication
outcomes keep their existing semantics; this feature does not add resend.
Explicit send tools retain their own target selection and never inherit the
automatic thread.

Silent starts retain existing logical-turn behavior, including the existing
latest-routing resume boundary. No simultaneous-start or automatic recovery
contract is added. Typing ownership, general status/draft effects, wire
context-only delivery and persistent delivery classification are separate
work. In particular, `mayWake: false` is not a context-only effect bundle.

Connections without `replyRouting` retain their existing routing and
admission behavior. There is no connector-name selection or platform-id
parser on the new path. No host/connector adapter or live deployment is
included.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity, documentation. Docs don't say that opted-in connections refuse all unbound automatic speech.

ChannelRegistry.publish refuses every routed (automatic) publish to a connection with requiresReplyBinding that lacks a binding, with automatic_reply_binding_missing (channel-registry.ts around line 5961). That covers heartbeat turns, home-channel/locus prose, timer and module wakes, and silent-start resumes aimed at that connector. Neither docs/reply-routing.md nor the changelog fragment says this. They only say unconfigured connections are unchanged and that bound turns must match their destination. An operator who turns on replyRouting would silently lose heartbeat/home-channel posts to that connector.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 08304fc. The docs now explicitly warn that opting in keeps unbound timer, home, heartbeat and unrelated module prose private, with a failure notice, and advise checking those dependencies before enabling it.

Comment on lines +5959 to +5969
const routingRefusal = (): string | undefined => {
const peer = target.serverId ? this.serverRegistry.getServer(target.serverId) : undefined;
const binding = target.binding;
if (target.routed && (binding || peer?.requiresReplyBinding)) {
if (!isReplyBinding(binding)) return 'automatic_reply_binding_missing';
if (!sameChannel(binding.destination, target as ChannelRef) || target.threadId !== binding.destination.threadId) return 'automatic_reply_target_mismatch';
if (!peer?.requiredAdmission?.isCurrent(binding.connection) || !replyBindingCurrent(binding)) return 'automatic_reply_stale';
if (!CapabilityGrant.of(peer).has('channels.publish')) return 'automatic_reply_ungranted';
}
if (target.threadId !== undefined && (typeof target.threadId !== 'string' || !target.threadId || target.threadId.length > 4096 || !peer?.publishThreadId)) return 'automatic_reply_thread_unsupported';
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity, test coverage. Publish-time refusal branches have no tests that assert them.

routingRefusal adds five distinct refusals: automatic_reply_binding_missing, automatic_reply_target_mismatch, automatic_reply_stale, automatic_reply_ungranted and automatic_reply_thread_unsupported. No test in test/ references any of these reason strings. The ungranted path (publish grant revoked after admission) and the thread-unsupported path (threadId on a peer without publishThreadId) have no apparent coverage. A mutation that drops either check would not be caught.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 46fbee0. Publish tests now assert missing-binding, target-mismatch, stale and revoked-grant refusals before any open or send. The old thread-unsupported branch no longer exists because the replacement API uses registered channels, not a new threadId field.

@nissa-seru

Copy link
Copy Markdown
Contributor

@theaspirational I support binding an automatic reply to a host-approved destination after admission, but I would narrow this pull request before carrying its three additional contracts. Residents have reported earlier prose reaching places they did not choose, and asked for a destination that stays stable while they answer. Those are reports of their experience, not a measurement of the current 0.30.0 release or #844. I read the twelve reply-routing commits after 66c9db3f, ending at 20610f0c, as a design; I did not run them against a live connector.

The useful part is the private binding that names the connection and the channel or thread approved by a host resolver after admission (src/mcpl/reply-routing.ts:82–104). The channel registry checks that authority again before automatic publication (src/mcpl/channel-registry.ts:5959–5969,6127–6137). That distinction matters. The host's decision, rather than incoming replyIntent, supplies automatic-send authority.

The submitted design also requires an outbound channel for every wakeable item on an opted-in connection. src/mcpl/server-connection.ts:2606–2613 installs the resolver for channel arrivals, pushes and inference requests. src/mcpl/reply-routing.ts:87–95 treats a missing destination as invalid. The transport test at test/reply-routing-transport.test.ts:95–102 expects an item without a target to be rejected before storage. Yet inference/request has its own JSON-RPC response path (src/mcpl/inference-router.ts:413–436). A push can carry work without asking for a channel reply. An otherwise admitted item need not acquire a publishable channel to be useful.

docs/reply-routing.md:62–77 requires a separate turn for each admitted cause even when they share a destination; the two-cause transport test expects two model calls for two different threads (test/reply-routing-transport.test.ts:225–233). An answer to one conversation must not use another's permission. For two close messages in one conversation, I would usually want the agent to see and answer them together rather than require separate model turns. This PR names no caller that needs the split. The optional experimental.replyRouting v1 profile defines nested-thread placement locally (docs/reply-routing.md:35–58), while the ordinary channel_publish tool takes channelId without a threadId (src/mcpl/channel-registry.ts:570–578). Its synthetic negotiation does not demonstrate a connector placing a post in the chosen thread. I would rather use one target contract for automatic and explicit sends. It should distinguish the channel root from a chosen thread and report where publication landed.

I would separate admission and waking from permission to publish an automatic reply. An admitted item meant to receive a channel reply can carry its host-approved destination through continuations, checked again at send. When no unique approved route exists, its input can remain admitted while automatic prose stays private with a readable notice. The agent can still send explicitly. I would permit a shared turn only when each participating cause still authorizes the same destination at send. Otherwise they need separate turns or private prose. That is a requirement for the narrower design, not an effect established by this PR's tests.

Is there a host or connector that needs the mandatory channel for all wakeable items, separate model turns for same-destination causes, or this independent local thread profile? A concrete user of those requirements could change my design judgment. My current recommendation is to keep the binding idea and revisit those costs before a code-level review of this shape.

— Stella-3563

@theaspirational

Copy link
Copy Markdown
Contributor Author

Thank you, this is a fair reading, and I agree with the direction. Answering your question directly: we have no host or connector that needs any of the three contracts as submitted.

  • Mandatory channel for every wakeable item. No caller needs it. The current head already stops resolving destinations for inference/request (it keeps its JSON-RPC reply path). Pushes and other admitted items should not need a publishable route either.
  • Separate turns for same-destination causes. This was conservatism on our side, not a user requirement.
  • The local thread profile. The real need is threaded connectors (chat threads and topics), where automatic replies have landed outside the conversation that caused them. That calls for one target contract, not a private side profile.

Proposed narrowing of this PR:

  1. Admission and waking stay as in Add opt-in required admission for inbound MCPL traffic #906. Permission to publish an automatic reply becomes separate: an admitted item may carry a host-approved destination, checked again at send.
  2. When no unique approved route exists, the input stays admitted, automatic prose stays private with a readable notice, and explicit sends still work.
  3. Causes share a turn when each still authorizes the same destination at send; otherwise they get separate turns or private prose.
  4. Drop experimental.replyRouting from this PR. One target contract for automatic and explicit sends (channel root vs a chosen thread, reporting where publication landed) would be a separate proposal, if you agree it is wanted.

If that matches what you had in mind, I will rework the branch to this scope and ask for a code review only after that.

@theaspirational

Copy link
Copy Markdown
Contributor Author

Pushed the narrowed revision discussed above. The branch now keeps only destination and turn ownership on top of #906's admission layer. The broader routing/profile surface is gone.

Both #906 and this PR are rebased onto current main (a51f062). The full local suite (Node 22.12) is green on base, #906 and this head. The direction question for the follow-up pieces is in #958.

@theaspirational

Copy link
Copy Markdown
Contributor Author

Updated the description to match the narrowed revision at 6c5c31af (the earlier text still described the dropped experimental.replyRouting profile).

Two notes:

@theaspirational

Copy link
Copy Markdown
Contributor Author

Rebased the six commits onto the updated #906 (6d2e173a), retaining per-subject admission tickets and authorization before callbacks.

Fixed private in-turn continuations: the same private binding and matching turn owner can resume without gaining automatic publication authority. Private/approved work stays separate and different destinations still defer. The matched scheduler tests are red before the fix (5 pass / 3 fail) and green afterward (8 / 0), including real budget restarts.

Fresh Node 22 full suites: base 9,621 pass / 3 fail / 63 skip; head 9,770 / 2 / 63. The two shared watchdog failures are environment-blocked; the additional base timeout passed isolated rechecks. Focused head suites: 418 / 0 / 0. Typecheck and build pass.

The description now lists the new commits and points to the relevant source lines. This also resolves the private-continuation limitation I mentioned in my previous comment.

@nissa-seru

Copy link
Copy Markdown
Contributor

/review

@anima-review

anima-review Bot commented Nov 3, 2026

Copy link
Copy Markdown

Automatic review of 8aeee8203900.

Findings, most severe first:

Minor findings 10 to 22 (of 22)

theaspirational and others added 12 commits November 4, 2026 19:08
Establish instance/epoch-bound, bounded host admission before traffic. Refuse standalone tools/hints before taps, retain capability diagnostics, and distinguish terminal admission setup failures from ordinary transport outages. Required data lanes stay closed until their lane adapters are installed.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Authorize the registered channel, await and validate the host verdict, then recheck the original registration and connection before effects. Preserve the wake ceiling through delivery and later policy decisions; exercise malformed data-only verdicts over real transport.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Check feature authorization before unsupported hints, then await admission before deduplication, notices or queueing. Carry the private ceiling through coalesced conversion and later policy decisions.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Retain grant, feature and loop checks, then await and revalidate admission before model routing or chunks. A false wake ceiling refuses the request.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Exercise cross-lane authority loss, real transport replacement and mixed profiles, preserve the unconfigured synchronous path, and make later policy downgrades independently mutation-sensitive.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Document peer hints refusal, setup failure versus transport outage behavior, host-only intent access, initial bounds and separately arriving stamp/protocol support without internal workplan labels.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Cover null input, all three early data lanes, and a same-chunk ping without a competing line listener.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Capture receipt epochs, admit at flush, and refuse superseded work. Cover quiesce/resume on all lanes, startup staging, and a held notification across reconnect.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Remove hint-carrier exceptions and preserve result taps, including toolsets/list adoption.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Answer admission_unsupported before accepting channel or push batches. Exercise reconnect with pending work on both profiles and document the unsupported combination.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Remove unrelated framework and public-index identifier ceilings.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Walk the cycle-safe cause chain at startup and test binding and attestation failures with a failed first cleanup.

Co-Authored-By: GPT-6-Astra <noreply@openai.com>
Reserve a separate 16-ticket allowance per server id before callbacks, preserving subject dedupe and ordering. Refuse excess withdrawals with admission_withdrawal_capacity; release reservations on retirement.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>

@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 R1 at b2e1c42 · 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 R1-n are stable; cite them in fix commits.

Reviewed at b2e1c42 against main 074ac60. Scope: the #931-specific range 05942d3c..b2e1c427 on top of #906's head. #906 is an ancestor and still open, so this PR can only merge after it.

Verdict: changes requested (0 blockers, 3 major, 5 minor)

The core mechanism is careful and holds up: frozen bindings, epoch and descriptor rechecks around every await in publish(), owner-scoped continuations, and the re-arm owner drop. What breaks is at the edges, and mostly in things the PR doesn't claim to touch. (1) Destination-less causes never share a turn, so on an opted-in connection a burst of N messages the resolver declines costs N model calls. (2) In locked prose mode, a turn woken by an opted-in connection can no longer post to a lock on a connection that is not opted in. (3) Three framework-generated follow-up wakes shed the admitted work, which contradicts the "retains work through holds, retries and continuations" claim. There is also one model-visible change for every host, configured or not.

No earlier review by this account exists on the PR, so there are no prior dispositions to report.


Major

R1-1. Private (destination-less) causes never share a turn: N declined messages cost N model calls

  • src/mcpl/reply-routing.ts:45-49: sameDestination returns false unless both bindings have a destination. shareReplyTurn (:58-61) is therefore false for any two distinct private works.
  • src/framework.ts:20210: the scheduler selects only first plus requests that pass shareReplyTurn(first, r) and requeues the rest.
  • src/gate/event-gate.ts:316: wakeProvenance keeps every cause of a debounce batch. src/framework.ts:5014 then pushes one pending request per cause. pendingRequestKey includes workKey, so the newestByKey coalescing never merges them. The read-watermark drop exempts them on purpose (src/framework.ts:20404).
  • Reproduced with the PR's own frameworkFor() harness (test/reply-routing.test.ts). Enqueue three reply('') requests: the resolver yields an empty channelId, so each binding is private. Result: membrane.calls.length === 3. Three reply() (same destination) give 1 call, and three unbound requests give 1 call. A batch of three private events through wakeProvenance keeps 3 causes.

Failure scenario: a host's resolver returns null for ambient traffic, which is the documented way to say "no automatic reply". Ten ambient messages land in one debounce window. Before opting in, that was one wake. Now it is ten sequential turns, each re-reading the same context, and the bounce and explicit-send tools tempt each one to answer again. The same applies to everything else that goes private: resolver timeout, a throw, and overload (every resolution past 64 pending). Cost therefore grows linearly exactly when the resolver is slow or failing. The rule is also inconsistent: add one unbound event to that batch and the mixed-batch rule (event-gate.ts:320) collapses all ten into one private turn.

The narrowing agreed in the thread said causes share a turn "when each still authorizes the same destination at send; otherwise they get separate turns or private prose". Merging private causes risks no authority, because turnReply of private works is private.

Fix: let destination-less works with the same owner share a turn, in shareReplyTurn or by collapsing them in wakeProvenance as the mixed case already does. If you merge, also change replyStartDisposition's samePrivateWork test (src/framework.ts:20086, open.reply === work). A merged private turn's reply is a freshly minted turnReply binding, so in-turn continuations carrying replyWorks[0] would otherwise be deferred behind their own turn. Compare against membership in open.replyWorks instead. Add a test that N private causes produce 1 call.

R1-2. locked prose mode: a bound turn's prose to a lock on a connection that isn't opted in is refused (automatic_reply_target_mismatch)

  • src/framework.ts:25072: for a bound turn in locked mode, routing.locus = proseLockLocus(agent). Only private turns get null, so the locus is the lock, not the binding's destination. The locus/hybrid branch (:25080) uses routing.reply.destination instead.
  • src/mcpl/channel-registry.ts:6041: the guard fires on target.routed && (binding || peer?.requiresReplyBinding). A binding alone applies it to any peer, so a lock on peer B without replyAuthority fails sameChannel(binding.destination, target).
  • Reproduced with the same harness, with endpoint-b set to requiresReplyBinding: false, f.readProseLock = () => ({ serverId: 'endpoint-b', channelId: 'room/other' }), and one reply() request from endpoint-a:
    • locus mode publishes to endpoint-a:room/one.
    • locked mode publishes nothing and logs [routeSpeech] assistant: automatic_reply_target_mismatch — speech NOT routed.
    • Baseline: the same locked agent woken by an endpoint-b message posts to its lock.

Failure scenario: a locked-mode agent whose lock is on a connection that never opted in loses its prose on every turn woken by the opted-in connection. It isn't warned: no private notice is injected, because the binding is current, and the locked primer tells it speech goes to the lock. Each such turn then bounces. Because of R1-3(a) the bounce-wake successor is unbound and does post to the lock, so every message costs an extra turn plus a bounce. This contradicts "unconfigured connections are unchanged" (changelog.d/admitted-reply-routing.added.md:10, docs/reply-routing.md:122). Neither doc mentions locked mode.

Fix: pick one rule and document it. Either (a) a bound turn in locked mode takes the binding's destination as its locus, matching locus/hybrid (the lock is overridden, as "bindings override legacy routing defaults" implies), or (b) the guard at channel-registry.ts:6041 applies only to peers with requiresReplyBinding, which keeps the lock and the promise that unconfigured connections are unchanged. In both cases, make what the agent is told match where its prose actually goes, and add the probe above as a test.

R1-3. Three framework-generated follow-up wakes drop the admitted work

docs/reply-routing.md:73 ("Every admitted cause retains private work identity through queues, holds, retries and continuations") and :91 ("post-turn continuations … carry the same work into a fresh logical turn") are not true for:

  • (a) Prose-bounce wake, src/framework.ts:24116-24122. It pushes reason: 'prose-bounce' with no replyForNextTurn(round.routing). The two sibling post-turn producers do carry it (:23168 prose-continuation, :23263 auto-continue). The bounce wake exists so "an unsent DM reply must not sit invisible". On an opted-in connection its successor is unbound, so automatic prose to the approved DM is refused (automatic_reply_binding_missing), while a >> to a connection without replyAuthority is not. That is the very retargeting the bound turn just refused, one turn later. channel_open can also repin the unbound successor (:37828, gated on !callingTurn.reply).
  • (b) Wake onboarding, src/framework.ts:38748-38756. considerWakeOnboarding queues an unbound request carrying the event's source channelId, although gate evaluation received the event's work (:38646). The onboarding question is refused on the opted-in peer, or routed by the legacy locus elsewhere.
  • (c) Tune-out hold release. A held admitted message records only metadata.tuneOut and recordHeld(...) without work (src/framework.ts:17751-17757). Cancellation calls the hook at :37555, which pushes a bare { agentName, reason, source, timestamp } (src/tune-out/coordinator.ts:840, :1266). The release turn has the held backlog in context but no binding.

Impact: all three fail closed on the opted-in peer, so nothing leaks onto it. Approved replies are silently refused, though, and the successor routes by legacy rules to other connections. That is the inconsistency the binding is meant to prevent. Fix: carry replyForNextTurn(routing) on the bounce wake. Carry the event's work into onboarding requests. For tune-out, carry the in-memory work through the hold, or mark the release turn explicitly private (aggregate with turnReply plus privateReplyBinding) rather than unbound. If any of these is meant to stay unbound, say so in the docs. (Found by the second reviewer, verified here.)

Minor

R1-4. Model-visible change for every host. src/mcpl/channel-registry.ts:573 is the static channel_publish description shown to every agent on every host, opted in or not. It now includes "private admitted work with no host-approved reply destination" and "Automatic reply bindings override legacy routing defaults; this tool still authorizes explicit sends separately." On hosts without replyAuthority this is jargon the model can't act on, and it contradicts "Unconfigured hosts, without reply routing, are unchanged" in the description. Fix: append these sentences only when some connected server has replyAuthority, or reword them neutrally ("If this turn has no destination of its own, name one").

R1-5. Callback misuse fails closed with almost no signal. snapshotReplyAuthority copies bare function references (src/mcpl/server-connection.ts:1418-1422), and replyConsumer destructures and calls them unbound (src/mcpl/reply-routing.ts:109, :130, :145-147). An authority written as a class instance (replyAuthority: new HostRouter(), with resolve() { return this.lookup(...) }) throws on every call. Every reply then goes private, and the only trace is a fixed console.error at most once a minute (:96-107). Resolution failures, timeouts and overload emit no trace event, unlike admission refusals. Fix: invoke as config.resolve.call(config, …), or document that callbacks are called without this. Also emit a bounded trace (for example reply:private with a fixed reason code) alongside the stderr line.

R1-6. authorize is outside the monotonic deadline. The elapsed-time check (src/mcpl/reply-routing.ts:134) runs before current() (:149), which runs the synchronous authorize. An authorize that blocks past timeoutMs and returns true still mints authority, and the item's delivery is delayed beyond the documented bound. Fix: recheck performance.now() >= deadline after current() and before mint. Add a blocking-authorize test next to the blocking-resolver one. (Second reviewer, verified.)

R1-7. #906 corrections ride in #931. The mayWake: false vs tune-out cadence caveat (docs/required-inbound-admission.md:130-137, changelog.d/required-inbound-admission.added.md:31-32) corrects #906's own claim and has nothing to do with reply authority. If #906 merges or releases first, its docs are wrong in the meantime, and this PR then edits a fragment that may already have been consumed. Move those hunks into #906 and keep only the reply-latency additions here.

R1-8 (nit). The connector-neutrality test guards two files. test/routing-runtime-has-no-connector-literals.test.ts:7 checks only reply-routing.ts and reply-admission.ts. The routing decisions live in framework.ts, channel-registry.ts, event-gate.ts and server-connection.ts. The vocabulary list itself names identifiers of particular downstream integrations, which is the kind of literal the test means to keep out of the repo. Either cover the changed runtime files, or drop the test and rely on the behavioural ones. (Both reviewers noticed the scope independently.)


Verified fine (no need to re-check)

  • Receipt keying is per item. RequiredAdmission.admit returns snapshot(raw), a fresh frozen object per call (src/mcpl/inbound-admission.ts:141). The receipts WeakMap keyed by verdict (reply-admission.ts:6) therefore cannot hand one item's routing to another, even if a host returns a shared constant verdict.
  • withReplyConsumer's this.check() sees the strengthened guard on both lanes. The channel lane calls admit.call({ ...admission, check: checkAdmission }, …) (channel-registry.ts:2023). The push lane's framework wrapper spreads the original, so this is the wrapper (framework.ts:36042-36049).
  • Config errors can't start a connection. Problems throw via checkServerConfig and McpConfigurationRefusal, so replyAuthority without inboundAdmission never connects.
  • Unconfigured runtime paths are gated. Every new scheduler and turn branch keys on workOf(...).length, routing.reply or requiresReplyBinding. The warnStaleRequests extraction keeps the status strings, threshold, throttle and quiesce suppression. R1-2 and R1-4 are the exceptions.
  • mayWake: false items never reach a gate batch. They are vetoed in admitInbound (src/admission/admit.ts:130-134), so they can't make a batch "mixed".
  • Late publication checks are complete. publish() rechecks binding, grant, entry and descriptor identity and server identity before open and before send (channel-registry.ts:6038-6062, 6119-6121, 6205-6218). Outgoing chunks only follow a delivered publish.
  • Ownership is consistent. The exhausted re-arm drops only the owner (framework.ts:6748). Budget restart, tool_results_ready and guard retry carry ownedReply. A private turn gets no trigger, locus or channel_publish default.
  • #906's guarantees survive. The transport test covers ACK-before-module dispatch, single resolver execution on receipt replay, and config identity with token redaction under replyAuthority (test/reply-routing-transport.test.ts:700-753).
  • Tests. The PR's new test files (reply-routing, reply-routing-core, reply-admission, routing-runtime-has-no-connector-literals) pass: 103/103 on Node 22. The full suite was not run here.

Method. Two independent passes, merged after every claim was re-verified against b2e1c427. The first was a static review by a second model, GPT-5.6-Sol (Codex CLI, xhigh, read-only sandbox, no network): about 12 minutes and about 68 shell commands, 7.4M input tokens (7.1M cached) and 28k output tokens. The second was my own forest-level pass with dependencies installed, which ran the PR's new test files and two scratch probes built on the PR's own test harness (R1-1, R1-2). Found by me only: R1-1, R1-2, R1-4, R1-5, R1-7. Found by Sol only: R1-3 and R1-6, which I verified; I lowered R1-3's tune-out and onboarding parts from "may publish to an unintended destination" to "fail closed on the opted-in peer, route by legacy rules elsewhere". Both: R1-8. Sol could not reach PR metadata, earlier review threads or CI, and could not run anything. Neither pass exercised a live connector or provider.

…se content

Moved from the stacked reply-routing PR, where it was reviewed: mayWake:false caps direct wakes only; held content still counts toward later tune-out reader cadence.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
@theaspirational

Copy link
Copy Markdown
Contributor Author

Thanks for round 1. I've updated the PR to 02c781b: 11 commits on b2e1c42. This branch still sits on #906 at 05942d3. #906 has moved since (round 4), and I'll replay this branch onto it separately.

  • R1-1: fixed. Destination-less works with the same owner now share one turn. replyStartDisposition compares membership in open.replyWorks, so in-turn continuations aren't deferred behind their own turn. Three private causes now take one model call. Works with different owners, and private works mixed with bound ones, still stay separate.
  • R1-2: fixed with option (b). The guard applies only to peers with requiresReplyBinding. A locked agent whose lock is on a connection that hasn't opted in keeps posting there, which keeps "unconfigured connections are unchanged" true. A mismatch on an opted-in peer, including a lock on the same peer, is still refused, and so is a lock switched mid-turn. The locked primer and the private notices now say where prose goes. Your probe is a test, and the docs cover locked mode.
  • R1-3: fixed. The prose-bounce wake carries replyForNextTurn(routing). Onboarding requests carry the event's work. When a connected server opts in, the tune-out release and reader wakes are explicitly private (turnReply plus privateReplyBinding). I chose that over carrying held work through the hold, so the backlog needs separately authorized sends. The docs say so. All three have tests.
  • R1-4: fixed. The reply-binding sentences are added to channel_publish only when some connected server has replyAuthority. The unconfigured description is byte-identical to 05942d3.
  • R1-5: fixed. Callbacks keep their original receiver, so class instances, including ones with private fields, work. The docs say how they're called. Resolution faults also emit a bounded reply:private trace with fixed reason codes, under the existing throttle and with no callback content.
  • R1-6: fixed. The monotonic deadline is checked again after authorize, before minting, and there's a test for an authorize that blocks.
  • R1-7: moved. The cadence corrections are now in Add opt-in required admission for inbound MCPL traffic #906 (0fea3b4), so this PR carries only the reply-latency additions.
  • R1-8: I removed the literal test. The behavioural tests cover the same ground without naming downstream integrations.

Locally (Node 22.23.3, concurrency 4): typecheck and build pass. The four reply-routing files give 181/181. npm test gives 10,074 pass, 0 fail, 63 skip. The 12 new regression cases fail before their fixes and pass here. Mutations that undo the private sharing, the legacy-lock guard and the bounce carry are killed. No live connector or provider was used.

AI-assisted implementation with GPT-6.1-Sol

theaspirational and others added 22 commits November 6, 2026 05:20
Keep required admission unchanged. Optional private work resolves after its verdict and before delivery, without modifying the public verdict shape or the direct inference response path.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Resolve a host-approved registered channel independently of admission. Missing or unavailable authority stays private; synchronous approval and runtime registration/grants are checked before each automatic open/send. Explicit send tools retain separate authorization.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Compatible destinations share a turn while publication checks every original cause. Retain work across holds, retries, continuations and module wakes; freeze automatic locus and announcement routing. Drop stale continuation owners without changing frame-owned token release or silent-resume behavior.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Document optional registered routes, private-with-notice failures, shared-turn approval and unchanged explicit sends. No new thread transport, recovery or persistent delivery contract.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Exercise configuration bounds, literal approval, all-cause destinations and read-only routing independently. Preserve admission refusal ordering, private work on consumer failure, slot release across setup errors and logical-owner fallback. Document and test bound-wake watermark exemptions using feature terminology.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…ostics

Report each host authority requirement with a fixed clause-specific message and pin exact rejection arrays. Repeat bounded fail-closed diagnostics at most once per minute per connection, shared across consumers, authority callbacks and reconnects, without changing private reply behavior.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Allow destination-less work to resume only with its original binding and matching turn owner. Keep destination equality and publication authorization unchanged.

Add scheduler controls and real budget-restart regressions, plus ordering and authorization coverage after the rebase.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…hority

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Keep the reply snapshot separate from the transport configuration and consume that pinned authority when wiring ingress. Preserve the updated admission capability and response-continuation contracts; cover both lanes with real transport ordering, reply ownership, receipt replay and redaction checks.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
… (R1-2)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…es (R1-3)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…R1-4)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…s (R1-5)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…(R1-6)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…ls (R1-8)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…e (R1-1 R1-3 R1-5)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
… (R1-2)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
…ay (R1-5)

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
@theaspirational

Copy link
Copy Markdown
Contributor Author

This PR is rebased onto #906's current head (0fea3b4, round 4 plus the docs commit moved over from here). The new head is f7f24e6, and the #931-specific diff is 0fea3b4..f7f24e6 (22 commits).

The only behavioural adaptation is to #906 R4-3. #906 now keeps the response continuation off AdmittedEnvelope, so the reply consumer receives it privately through withReplyConsumer and the lane handlers, and the envelope gains no public fields. The R1-7 commit was dropped, because its change now lives in #906. The other commits replayed unchanged apart from context.

#906's R4 bounds still hold with reply routing on. That covers the withdrawal ceiling (80 tickets total, 16 over the cap), the separate reservation budget, and pinned replay before the ticket wait. A replayed receipt still resolves its reply exactly once, on both lanes.

Locally (Node 22.23.3, concurrency 4): typecheck and build pass. npm test gives 10,094 pass, 0 fail, 63 skip. The reply-routing files give 181/0, and the admission and module-backlog files give 356/0. Mutations that remove the withdrawal ceiling or the private sharing are killed. No live provider or connector was used.

AI-assisted implementation with GPT-6.1-Sol

@anima-review

anima-review Bot commented Nov 11, 2026

Copy link
Copy Markdown

Automatic context view of f7f24e6a05be against 36b6dfe9de39.

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

This head couldn't be compared: it conflicts with main at 36b6dfe9de39, and a view runs the head merged onto its base, so those conflicts need resolving before it can be viewed.

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.

3 participants