Skip to content

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

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

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

Conversation

@rufalupa666-netizen

Copy link
Copy Markdown

What happened in production

Three times in one week a private WebUI reply from a connectome-host resident was published to a Discord channel (2026-09-15 noticed by Lari; 2026-09-22 18:48 UTC and 2026-09-23 12:32 UTC on our QA resident, journal lines in the trace linked below). The resident had not called a send tool.

Mechanism

A turn triggered from WebUI/TUI/CLI/headless/API has no channel. At the turn-start freeze every no-channel turn was treated as ambient and fell through home ?? activeChannel ?? defaultPublishChannel — the last being the process-global most recent incoming channel (channel-registry.ts:471-495, :900). The WebUI event carries source: 'tui', but nothing at the freeze looked at it. So a private reply inherited whichever Discord channel last spoke.

Change

src/framework.ts, at the freeze: when the trigger is an external-message from a non-channel source (tui / cli / headless / api) with no channelId, pin null before resolveLocus — so home, active and global fallback are all bypassed. routeSpeech(null) then emits its existing no-locus marker instead of guessing. Ten lines.

Deliberately not touched:

  • channel-triggered and heartbeat turns (unchanged fallback, tested);
  • gate/wake provenance (telemetry-only per 4ff86f0; not used here);
  • channel_open repinning the current turn — the 2026-07-31 call; the "open for reading" counter-case is a separate question for its owner.

This is the same rule the framework already applies to subagents (test/subagent-prose-routing.test.ts) and to the subconscious (forced explicit): a turn that didn't come from a channel doesn't get a guessed one.

Tests

test/trunk-channel-routing.test.ts, four cases: WebUI turn after Discord traffic → no pin, resolveLocus not called, routeSpeech receives null; WebUI turn for a resident with a home channel → still no pin; channel-triggered turn → pins its channel; heartbeat → global fallback unchanged.

Receipts on 060aca8 (v0.19.0): npm run build && npm test → 971 tests, 967 pass, 0 fail, 4 skipped; tsc --noEmit clean; git diff --check clean. Changelog fragment changelog.d/non-channel-origin-locus.changed.md names who is affected.

Adjacent work

No overlap found with #177 (failed sends release held prose — under this change the release goes nowhere for a WebUI turn, which is the intended fail-closed) or #182 (outbox sits in routeSpeech, downstream of locus resolution). Discussed in #connectome-qa (2026-09-25 / 09-28); Ferry confirmed the boundary and the "override home too" sharpening.

Co-developed with qa-engineer, the connectome-host resident that hit this.

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5 Tier: apex

[Medium risk] Routing logic for turns from non-channel surfaces.

The PR is not safe to merge while a silent heartbeat batched ahead of a private message can cause channel publication of the reply.

Findings

  1. P1 Security Private reply can publish ▶
Fix with agent prompt
### Issue 1
src/framework.ts:8187
When a silent heartbeat is queued before a private WebUI/API message, this check sees only the heartbeat and does not separate the private request. The mixed batch also removes the heartbeat’s prose suppression. If the resident has a home or last-inbound channel, a reply informed by the private message can be published there instead of staying private.

**How this was verified:** The private message enters the same request batch, but the selected trigger has no private-origin flag and the mixed-batch suppression check permits channel speech delivery.

---

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

Summary

The PR prevents non-channel turns from inferring a publication locus and binds channel_open repinning to its issuing turn.

  • Private-first batches retain channel wakes for a separate turn.
  • A silent-heartbeat-first batch can still publish a reply informed by a private message to a channel.

Reviews (4) · Last reviewed commit: "chore: move nonChannelOrigin to the end ..."

Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts
Comment thread src/framework.ts
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
Comment thread src/framework.ts
Comment thread src/framework.ts Outdated
@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
rufalupa666-netizen and others added 3 commits October 2, 2026 20:02
A WebUI/TUI/CLI/headless/API turn has no channel. The freeze at turn
start treated every no-channel turn as ambient and fell through to
home ?? activeChannel ?? defaultPublishChannel, where the last is the
process-global most recent incoming channel. So a private WebUI reply
inherited whichever Discord channel last spoke. Three incidents in one
week on a production resident (2026-09-15, 09-22, 09-23).

Freeze null before resolveLocus when the trigger is an external-message
from a non-channel source (tui/cli/headless/api); the source is already
on the InferenceRequest. Channel-triggered and heartbeat turns are
unchanged. No gate/wake provenance involved. channel_open's current-turn
repin (the 2026-07-31 call) is deliberately untouched — separate
question for its owner.

Tests: four cases in test/trunk-channel-routing.test.ts. Full suite
971 (967 pass, 4 skipped), tsc --noEmit clean, git diff --check clean.

Co-developed with qa-engineer (connectome-host resident).
… 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
// requests[0] in that mixed batch bypassed the turn lock because a
// restart existed, then treated the continuation as a fresh turn.
const trigger = budgetRestart ?? requests[0];
if (trigger.nonChannelOrigin) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Private reply can publish When a silent heartbeat is queued before a private WebUI/API message, this check sees only the heartbeat and does not separate the private request. The mixed batch also removes the heartbeat’s prose suppression. If the resident has a home or last-inbound channel, a reply informed by the private message can be published there instead of staying private.

How this was verified: The private message enters the same request batch, but the selected trigger has no private-origin flag and the mixed-batch suppression check permits channel speech delivery.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8187

Comment:
**Private reply can publish** When a silent heartbeat is queued before a private WebUI/API message, this check sees only the heartbeat and does not separate the private request. The mixed batch also removes the heartbeat’s prose suppression. If the resident has a home or last-inbound channel, a reply informed by the private message can be published there instead of staying private.

**How this was verified:** The private message enters the same request batch, but the selected trigger has no private-origin flag and the mixed-batch suppression check permits channel speech delivery.

---

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

@greptile-apps

greptile-apps Bot commented Oct 2, 2026

Copy link
Copy Markdown

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

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.

2 participants