Skip to content

system-event-shape consumer-path leakage; propose per-event audience classification #69492

@lukeboyett

Description

@lukeboyett

Summary

Under umbrella #69208 Track B ("Delivery-mirror and consumer-path leakage"), the 2026-04-20 cleanup comment names #39469 as the Track B canonical and scopes the supporting issues (#38061, #33263, #5964) to assistant-message-shape artifacts. PR #69217 is the active Track B fix for that slice — it adds isTranscriptOnlyOpenClawAssistantMessage in src/config/sessions/transcript.ts and wires it into chat.history, sessions.get, and the sessions history HTTP reader.

The same consumer-path boundary also leaks system-event-shape artifacts — enqueueSystemEvent outputs from the exec runtime, cron, and heartbeat flows. These events carry a different object shape (no role, no provider, no model; their fields are text, ts, contextKey, deliveryContext, trusted), so #69217's predicate does not filter them. There is no canonical Track B issue for this shape today, and the fixes currently in flight for specific symptoms are per-surface or per-channel.

This issue is intended as the system-event-shape canonical under Track B, and proposes a per-event audience: 'internal' | 'user-facing' | 'auto' classification as the underlying primitive. The consumer-path filter would follow as a sibling predicate to #69217's, in the same file.

Status update — 2026-05-05

Posted as a status amendment on the original 2026-04-20 issue. The original analysis below is preserved for historical context. Many of the referenced cross-refs have changed state since filing; this section documents the deltas and the live implementation track.

Track B canonical (#39469) — open → closed 2026-04-24 as already-implemented via e95efa4373 fix(sessions): dedupe redundant delivery mirrors and follow-up d842ec4179 fix: tighten delivery mirror dedupe (#67185), both shipped in v2026.4.15. That fix is in the transcript-append layer (src/config/sessions/transcript.ts): before appending a provider:"openclaw", model:"delivery-mirror" assistant message, the writer trims visible text and reuses the latest assistant entry when the trimmed text matches. Important framing: that closure addresses assistant-message-shape Track B (the duplicate-mirror symptom). It does not touch the system-event-shape lane that this issue's subject is about — system events have a different object shape and do not flow through appendAssistantMessageToSessionTranscript. The system-event-shape lane remains unaddressed at the canonical layer.

Track B canonical-adjacent PRs (state at filing → now)

Adjacent reshape that affects this PR's design (not a supersession)

Direct system-event-shape reports (original A list)

Cross-cuts assistant-message-shape (B)

Cross-cuts Track A — duplicate persistence (C, canonical #66443 still open)

Adjacent / overlapping FRs

Correction on prior precedent

Implementation track

  • feat(events): add audience field for hidden runtime-context system events #72201 feat(events): add audience field for hidden runtime-context system events is the live implementation slice from this proposal. Currently OPEN, rebased onto current main (HEAD e68ce9353c). Adds optional SystemEvent.audience and wraps internal-audience events at drainFormattedSystemEvents in the existing INTERNAL_RUNTIME_CONTEXT_* delimiters; migrates exactly one producer (queueCronAwarenessSystemEvent) for review tractability. No batch migration committed.

Self-correction on the original "Consumer-path filter" section
That section proposed a sibling predicate to #69217's isTranscriptOnlyOpenClawAssistantMessage. With #69217 closed without merge AND the v2026.4.15 transcript-dedup fix landing producer-side, the consumer-side predicate approach is no longer actively pursued by this issue#72201's wrap-on-drain primitive supplants it. The original "Proposed Shape" → "Consumer-path filter" subsection is preserved below for context but should be read as superseded by the wrap-on-drain design.

Why This Issue Exists

Several open reports describe system-event text landing in user-visible chat transcripts across multiple channels. The individual reports differ in surface and exact symptom, but the shared pattern is:

  1. An internal producer (maybeNotifyOnExit, heartbeat relay, cron) calls enqueueSystemEvent with a SystemEvent payload.
  2. That event is consumed by downstream readers (chat.history, sessions.get, sessions history HTTP, channel dispatchers) that have no way to distinguish "this event was meant for internal agent awareness" from "this event should reach the user."
  3. The event is rendered, persisted, or relayed as if it were user-facing conversational content.

Existing visibility controls in the runtime (tools.exec.notifyOnExit, visibility.showAlerts, heartbeat.target, suppressToolErrors, ANNOUNCE_SKIP_TOKEN) are each too coarse to express this distinction at event granularity. Prior work that closed similar reports by adding boolean flags (e.g., #22823, #20193) has not held up in practice — #19894 showed that notifyOnExit:false + suppressToolErrors still leaks sub-agent exec failures, which suggests that boolean notify flags are not the right primitive.

The ExecToolDefaults.trigger field already carries 'heartbeat' / 'cron' / caller context through runExecProcess, but maybeNotifyOnExit never reads it. That signal is present in the runtime today but unused for classification.

Related Issues

A. System-event-shape reports (direct)

B. Cross-cuts assistant-message-shape (addressed by #69217)

C. Cross-cuts Track A (duplicate persistence — canonical #66443)

These overlap with Track A at the persistence layer. An event-level audience classification would prevent persistence for internal events, which partially addresses them from this direction; the Track A idempotency work covers the remainder.

Adjacent (not addressed here)

Overlapping feature requests

Prior attempts at this problem class (closed)

Related PRs

In-flight Track B slices

Historical precedent

Current Working Hypotheses

1. Classification gap at the event layer

SystemEvent (src/infra/system-events.ts) has text, ts, contextKey, deliveryContext, trusted — no field that expresses the producer's intent about whether the event should reach the user. deliveryContext routes where an event goes; it does not say whether the event should appear on any user-facing surface.

ExecToolDefaults.trigger (src/agents/bash-tools.exec-types.ts:12) already carries 'heartbeat' / 'cron' / caller context through to the runtime. maybeNotifyOnExit in src/agents/bash-tools.exec-runtime.ts:279–308 does not consult it when deciding what to emit. The signal is present, unused.

2. Consumer-path write-through to visible transcripts

buildExecEventPrompt({ deliverToUser: false }) in src/infra/heartbeat-events-filter.ts:41–54 produces text that literally says "Handle the result internally. Do not relay it to the user unless explicitly requested." #68508 shows this exact directive text landing in visible chat transcripts, which suggests the filter is happening at the agent-instruction level but not at the transcript-persistence level. The pattern #69217 establishes for assistant-message-shape artifacts — a shared predicate called at consumer-path boundaries — applies directly to the system-event shape as well.

3. Per-surface patches are running faster than the underlying problem shrinks

The three Track B PRs filed in the last 48 hours each address a different slice (artifact shape, one UI surface, one misreferenced flag). They collectively close roughly one of the open reports in the system-event cluster. The remaining reports span Matrix, Discord, Telegram, Teams, Mattermost, TUI, and webchat; without an event-level primitive, each new surface needs its own filter and each format change (timezone prefix, text wording, output length) re-opens existing filters. An event-level audience field is the layer at which this can be closed consistently.

4. Boolean notify flags are structurally insufficient

Closed-issue precedent #19894 showed that notifyOnExit:false + suppressToolErrors compositions still leak sub-agent exec failures. Adding more top-level booleans has not held. An enum field on the event itself is a different concept, not another boolean.

Proposed Shape

This is a proposal, not a committed design. Happy to revise per maintainer preference on any of these.

Core primitive

Extend SystemEvent with an optional audience field:

type SystemEventAudience = 'internal' | 'user-facing' | 'auto';

type SystemEvent = {
  text: string;
  ts: number;
  contextKey?: string | null;
  deliveryContext?: DeliveryContext;
  trusted?: boolean;
  audience?: SystemEventAudience;
};

Default is 'auto'. No existing call site has to change.

  • 'user-facing' — reach the user when routing and visibility allow.
  • 'internal' — agent/session/log awareness only. Heartbeat may read; agent may reason; appears in session state. Does not flow to user-facing chat surfaces. Not written to chat.history as a visible entry.
  • 'auto' — defer to downstream (current behavior).

Spawn-time intent

Extend ExecToolDefaults and ProcessSession with completionAudience?: SystemEventAudience. Derive at maybeNotifyOnExit:

completionAudience explicit from defaults?                → use it
else session.trigger ∈ {'heartbeat','cron','subagent'}?    → 'internal'
else                                                       → 'user-facing'

This activates the existing-but-unused trigger field. Matches #66460's reporter's explicit fix proposal, generalized beyond cron.

Relay-time honoring

In src/infra/heartbeat-runner.ts (canRelayToUser computation around line 812):

const canRelayToUser = Boolean(
  delivery.channel !== 'none' &&
  delivery.to &&
  visibility.showAlerts &&
  !everyPendingExecEventIsInternal(events)
);

buildExecEventPrompt({ deliverToUser }) already handles the downstream wording and does not need to change.

Consumer-path filter (sibling to #69217)

Peer predicate in src/config/sessions/transcript.ts, alongside isTranscriptOnlyOpenClawAssistantMessage:

export function isInternalAudienceSystemEvent(event: unknown): boolean {
  if (!event || typeof event !== "object" || Array.isArray(event)) {
    return false;
  }
  const typed = event as { audience?: unknown };
  return typed.audience === 'internal';
}

Wired into the same consumer-path boundaries #69217 touches. The exact call-site shape — two peer predicates called per consumer, or one combined dispatcher that inspects entry shape — is a maintainer preference. Happy to match whatever #69217 settles on.

Config fallback

Optional per-channel default for unspecified events:

{
  channels: {
    matrix: {
      defaultCompletionAudience: "internal"
    }
  }
}

Backward-compat: when unset, behavior collapses to current visibility.showAlerts semantics. Superset, not replacement. Subsumes #13911.

What this does not change

What This Issue Should Track

  1. Whether the Track B scope should be expanded to include system-event-shape artifacts, or whether this should be treated as a separate track.
  2. Whether the per-event audience primitive is the right direction, versus alternatives such as extending per-channel showAlerts to be per-trigger, or extending trusted to be a tri-state visibility field.
  3. Naming — audience vs visibility on the event; completionAudience vs completionVisibility on defaults. Draft uses audience to reduce conflation with the existing visibility.showAlerts channel-level knob.
  4. Consumer-gate shape — two peer predicates or a single combined dispatcher.
  5. Ordering relative to Hide transcript-only OpenClaw history artifacts #69217 — waiting for it to land and layering on top, parallel branches, or expanding Hide transcript-only OpenClaw history artifacts #69217's scope.

Suggested Exit Criteria

This issue should not close until:

  1. SystemEvent carries an audience field and the exec runtime derives a value from trigger context where available.
  2. Heartbeat relay consults event-level audience in addition to per-channel visibility.
  3. Consumer-path readers gate on audience in the same boundaries Hide transcript-only OpenClaw history artifacts #69217 gates for assistant-message-shape artifacts.
  4. At least [Bug]: cron-owned exec completion events are incorrectly relayed to the user by heartbeat #66460, [Bug]: WebChat / Control UI: System event messages leaked into visible transcript after async exec #68508, and the system-event portion of Internal exec/heartbeat events leak into visible Control UI chat transcript #66814 are verified fixed by reporters.

Notes On Issue Closure

Open issues in the list above with unique reproductions or unique affected surfaces should stay open until their exact symptom is verified fixed. This issue is meant to consolidate analysis and design, not to auto-close linked issues.

If #69366 merges and closes #68992 for Control UI only, non-Control-UI reports of the same symptom should stay open and be tracked here until the backend fix lands.


Happy to contribute an implementation PR along these lines if the direction is accepted, following the same iterative bot-review cadence as #67508.

AI-assisted research: cross-indexed open and closed issues, traced the event pipeline in 2026.4.19-beta.2, and drafted this issue with Claude Code (Claude Opus 4.7, 1M context). Fully reviewed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions