Skip to content

fix(hub): capture AGENT_NOTIFY_SUMMARY from peer user-role deliveries - #1738

Open
heavygee wants to merge 8 commits into
tiann:mainfrom
heavygee:fix/notify-peer-user-role-ingest
Open

heavygee wants to merge 8 commits into
tiann:mainfrom
heavygee:fix/notify-peer-user-role-ingest

Conversation

@heavygee

@heavygee heavygee commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • ingestNotifySummaryFromMessage rejected non-agent message shapes, so hapi-ping-peer / attributed peer deliveries (role=user text with a trailing AGENT_NOTIFY_SUMMARY) never became work-graph work_ad rows.
  • Now extracts plain text from user-role inbound the same way cause stamping already does, then applies the existing notify footer parser.
  • Agent path unchanged.

Test plan

  • bun test hub/src/sync/workGraphNotifyIngest.test.ts (includes new peer user-role case)
  • Dogfood: peer ping with trailing AGENT_NOTIFY_SUMMARY → work_ad / Session Log capture on a hub that has this tip

Issues

Fixes #1737

hapi-ping-peer lands as role=user; work-graph ingest only scraped agent
shapes, so A2A footers never became work_ad rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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.

Findings

  • [Major] Restrict user-role notify ingestion to identifiable peer deliveries - the new isInboundUserMessage branch accepts every ordinary user prompt, so a web/Telegram/CLI message ending in a valid footer is persisted as an append-only row with reserved AGENT_NOTIFY_SUMMARY provenance and an agent principal. The real hapi ping-peer path posts to the normal messages endpoint and is stamped sentFrom: "webapp" (cli/src/modules/pingPeer/pingPeer.ts:350, hub/src/web/routes/messages.ts:144), while the added test invents sentFrom: "peer" (hub/src/sync/workGraphNotifyIngest.test.ts:257). The implementation therefore cannot distinguish the intended peer traffic from pasted or authored user content. Evidence: hub/src/sync/workGraphNotifyIngest.ts:460.

    Suggested fix:

    function isPeerNotifyDelivery(content: unknown): boolean {
        const record = asRecord(content)
        const meta = asRecord(record?.meta)
        return record?.role === "user" && meta?.notifySource === "peer"
    }
    
    if (isPeerNotifyDelivery(content)) {
        return extractInboundCauseText(content)
    }

    Stamp notifySource: "peer" only in a dedicated hub peer-delivery path, not from the ordinary message request body. Add a negative test proving a sentFrom: "webapp" user footer is ignored and an integration test using the real ping-peer route.

Summary

Review mode: initial

One Major provenance issue found. The patch currently elevates arbitrary user-role footer text into agent-authored work-graph audit rows.

Testing

Not run (automation). GitHub integration check passed; main test check was pending during review. Missing coverage: ordinary web user footer rejection and real ping-peer route ingestion.

HAPI Bot

Comment thread hub/src/sync/workGraphNotifyIngest.ts Outdated
Restrict user-role AGENT_NOTIFY_SUMMARY elevation to deliveries marked
meta.notifySource=peer so ordinary web/CLI prompts cannot mint work_ads.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

heavygee commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed Major: user-role notify ingest is now gated on meta.notifySource === "peer".

  • SendMessageRequest.notifySource: "peer" (optional)
  • hub stamps it onto message meta
  • hapi ping-peer / MCP path sends notifySource: "peer"
  • ordinary webapp footers no longer elevate (regression test added)

Tip: will be on the next push after this comment.

@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.

Findings

  • [Major] Peer-stamped REST sends bypass the notify-ingest hook — SyncEngine.sendMessage() forwards the marker to MessageService, but that service emits directly through EventPublisher; ingestNotifySummaryFromMessage() is only called by handleRealtimeEvent(), which receives CLI socket events. The stored peer user message therefore never becomes a work-graph row. Evidence: hub/src/sync/syncEngine.ts:1013, with surrounding flow at hub/src/sync/messageService.ts:891 and hub/src/sync/syncEngine.ts:490.

    Suggested fix:

    const { actualSessionId, createdAt, message } =
        await this.messageService.sendMessage(sessionId, payload)
    this.ingestNotifyMessage(actualSessionId, message)
  • [Major] notifySource remains caller-controlled — the ordinary authenticated message schema accepts the marker and the route passes it through unchanged. Any web/API caller in the namespace can therefore create an append-only AGENT_NOTIFY_SUMMARY row whose principal is recorded as an agent. Evidence: shared/src/apiTypes.ts:536, hub/src/web/routes/messages.ts:151.

    Suggested fix:

    export const SendMessageRequestSchema = z.object({
        text: z.string(),
        localId: z.string().min(1).optional(),
        attachments: z.array(AttachmentMetadataSchema).optional(),
        scheduledAt: z.number().int().positive().nullable().optional(),
        deliveryMode: MessageDeliveryModeSchema.optional()
    })
    
    // Set notifySource only inside a trusted CLI socket/RPC peer-delivery handler.
    await engine.sendMessage(sessionId, { text, notifySource: 'peer' })

Questions

None.

Summary

Review mode: follow-up after new commits

Two Major issues remain: the actual ping-peer delivery path does not invoke notify ingestion, and the new provenance marker is exposed to ordinary message clients.

Testing

Not run (automation). Current checks: integration passed; main test pending. Missing coverage: an end-to-end engine.sendMessage(..., { notifySource: 'peer' }) assertion that a work-ad is inserted, plus rejection of a spoofed marker from the ordinary REST route.

HAPI Bot

Comment thread hub/src/sync/syncEngine.ts
Comment thread shared/src/apiTypes.ts Outdated
REST peer deliveries now go through POST /peer-messages (hub stamps
meta.notifySource=peer) and SyncEngine.sendMessage runs notify ingest.
Ordinary /messages no longer accepts a client notifySource field.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

heavygee commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the two new Majors on tip (pushing):

  1. Ingest wiring: MessageService.sendMessage returns the stored message; SyncEngine.sendMessage calls the same captureNotifyFromMessage helper used by handleRealtimeEvent. Integration tests cover peer vs ordinary REST sends.
  2. Forgeability: removed client notifySource from SendMessageRequestSchema. New POST /api/sessions/:id/peer-messages stamps meta.notifySource=peer server-side only. hapi ping-peer posts there.

Will ping Ready YES after CI + fresh cold-review on this tip.

@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.

Findings

  • [Major] The peer endpoint does not establish or preserve a trusted sender identity — every /api/* caller receives the same owner/namespace JWT authorization, so any browser or Telegram client in the namespace can call this new route and make the hub stamp notifySource: 'peer'. The request also carries no authenticated source session, so captureNotifyFromMessage(actualSessionId, message) uses the recipient session and the work-ad builder records that recipient as source_ref and principal.id. Reserved, append-only AGENT_NOTIFY_SUMMARY rows are therefore forgeable and attributed to the wrong agent. Evidence: hub/src/web/routes/messages.ts:160, hub/src/web/routes/messages.ts:182, hub/src/sync/syncEngine.ts:1007, with resulting attribution at hub/src/sync/workGraphNotifyIngest.ts:430 and hub/src/sync/workGraphNotifyIngest.ts:437.

    Suggested fix:

    const source = requireSessionScopedPeerAuth(c, engine)
    if (source instanceof Response) return source
    
    await engine.sendPeerMessage({
        sourceSessionId: source.sessionId,
        targetSessionId: sessionId,
        text: parsed.data.text
    })
    // Build source_ref/principal from sourceSessionId; keep related_session_id as targetSessionId.

Questions

None.

Summary

Review mode: follow-up after new commits

The prior ingest-path gap is resolved. One Major provenance issue remains: the replacement endpoint is callable with an ordinary namespace JWT and cannot identify the sending session, so it can create forged, recipient-attributed audit rows.

Testing

Not run (automation; PR code was not executed). Current checks: integration passed; main test pending. Missing coverage: reject an ordinary web/Telegram JWT at peer-messages, and assert a successful peer delivery records the authenticated source session as the work-ad principal while retaining the recipient as the related session.

HAPI Bot

Comment thread hub/src/web/routes/messages.ts Outdated
Move peer delivery to POST /cli/sessions/:source/peer-messages so web JWT
cannot mint notifySource=peer, and attribute work_ad source_ref/principal
to the sending session.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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.

Findings

  • [Major] Keep notify/cause history scoped to the authoring principal — the changed query now treats every notify work-ad related to the recipient as one history, including a peer-authored row whose sourceRef is another session. A peer delivery is inserted as an already-invoked user row, but its own ingest excludes that row at the assistant-sequence boundary and records no causeMessageId. On the recipient's next assistant summary, the legacy fallback sees the peer work-ad's messageId and marks that peer inbound consumed, so the response loses the peer handoff as its cause. Evidence: hub/src/sync/workGraphNotifyIngest.ts:260, with the consuming fallback at hub/src/sync/workGraphNotifyIngest.ts:278 and cause exclusion at hub/src/sync/workGraphNotifyIngest.ts:325.

    Suggested fix:

    function listPreviousWorkAds(
        store: Store,
        namespace: string,
        sessionId: string,
        principalSessionId: string
    ): WorkGraphEvent[] {
        return store.workGraph
            .listWorkAdsByRelatedSession(namespace, sessionId)
            .filter((event) => (
                event.provenance === 'AGENT_NOTIFY_SUMMARY'
                && event.sourceRef === principalSessionId
            ))
    }
    
    const peerSourceSessionId = extractPeerSourceSessionId(input.content)
    const principalSessionId = peerSourceSessionId ?? input.sessionId
    const previousWorkAds = listPreviousWorkAds(
        input.store,
        input.namespace,
        input.sessionId,
        principalSessionId
    )

Questions

None.

Summary

Review mode: follow-up after new commits

One Major causality regression remains on the latest head: mixing peer-authored and recipient-authored work-ad history consumes the peer handoff before the recipient's response can cite it.

Testing

Not run (automation; PR code was not executed). GitHub status at review time: integration and drift-gate passed; test pending. Missing coverage: ingest a peer-stamped user footer, then a recipient assistant footer, and assert the second work-ad retains the peer message as causeMessageId.

HAPI Bot

Comment thread hub/src/sync/workGraphNotifyIngest.ts Outdated
Peer work_ads must not consume recipient cause history. Filter previous
notify rows by sourceRef === principalSessionId and regression-test that
a recipient assistant summary still cites the peer handoff as cause.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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.

Findings

  • [Major] Preserve standalone hapi ping-peer delivery — pingPeer() now requires HAPI_SESSION_ID before authentication or delivery, so the documented CLI command fails from a normal shell even when hapi auth login supplied valid hub credentials. Evidence: cli/src/modules/pingPeer/pingPeer.ts:490; documented standalone command context: cli/README.md:40.

    Suggested fix:

    const sourceSessionId = resolveSourceSessionId(options.sourceSessionId)
    
    if (sourceSessionId) {
        if (matched.id === sourceSessionId) {
            throw new PingPeerError('bad_args', 'cannot ping the source session itself')
        }
        await sendPeerMessage(apiUrl, accessToken, sourceSessionId, matched.id, message, http)
    } else {
        await sendMessage(apiUrl, jwt, matched.id, message, http)
    }

    Make resolveSourceSessionId return string | null; attributed peer delivery remains used in-session, while the old JWT message path preserves standalone delivery without minting the peer stamp.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One Major CLI regression remains. The previous principal-history finding is addressed on the current head.

Testing

  • Not run (automation; PR code was not executed). GitHub checks test, drift-gate, and integration passed. Missing coverage: pingPeer without HAPI_SESSION_ID, plus route-boundary tests for invalid, self, inactive, and cross-namespace source/target sessions.

HAPI Bot

Comment thread cli/src/modules/pingPeer/pingPeer.ts
Attributed /cli peer-messages when a source session is known; otherwise
fall back to ordinary /api messages so shell ping-peer still delivers.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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.

Findings

  • [Major] Trust peer provenance only from the dedicated route - the new parser treats persisted meta.notifySource/sourceSessionId as authoritative, but the CLI Socket.IO message payload is unknown and is stored verbatim after checking only the target sid. A namespace-authenticated socket client can therefore submit a peer-stamped user row with any source id, including a nonexistent or cross-namespace id, and mint an append-only agent principal that the peer route never authenticated. Evidence: hub/src/sync/workGraphNotifyIngest.ts:465; related socket path: hub/src/socket/handlers/cli/sessionHandlers.ts:106.

    Suggested fix:

    type NotifyIngestInput = {
        // ...
        trustedPeerSourceSessionId?: string
    }
    
    const peerSourceSessionId = input.trustedPeerSourceSessionId?.trim() || null
    const plainText = peerSourceSessionId
        ? extractInboundCauseText(input.content)
        : extractAgentNotifyText(input.content)

    Pass trustedPeerSourceSessionId only from SyncEngine.sendMessage when the validated peer route supplied it; do not enable peer-user elevation from handleRealtimeEvent.

  • [Major] Do not assign recipient prompts as causes of peer-authored rows - after selecting the peer as principal, ingest still runs generic turn-cause selection over the recipient session. If the target has any unconsumed inbound before the peer delivery, that unrelated prompt becomes the peer work-ad causeMessageId/causeText in the append-only ledger. Evidence: hub/src/sync/workGraphNotifyIngest.ts:502, with the scan at hub/src/sync/workGraphNotifyIngest.ts:512.

    Suggested fix:

    const causeResult = peerSourceSessionId
        ? {
            cause: null,
            previousEventId: previousWorkAds.at(-1)?.id ?? null
        }
        : resolveWorkAdCause({ messages, previousWorkAds, assistantSeq })
    
    const { cause, previousEventId } = causeResult
  • [Minor] Tag peer work-ads with the sender flavor - captureNotifyFromMessage resolves the recipient session and passes its flavor even when sourceRef/principal are changed to the peer source. A Claude-to-Codex delivery is therefore recorded with flavor:codex. Evidence: hub/src/sync/syncEngine.ts:1028.

    Suggested fix:

    const principalSession = peerSourceSessionId
        ? this.getSession(peerSourceSessionId)
        : session
    
    flavor: principalSession?.metadata?.flavor ?? null

Questions

  • None.

Summary

Review mode: follow-up after new commits

Three issues remain in the latest peer-attribution implementation: untrusted socket metadata can mint peer provenance, peer rows can acquire unrelated recipient causes, and peer flavor tags use the recipient.

Testing

  • Not run (automation; PR code was not executed).
  • Missing coverage: forged peer metadata through the CLI socket, a prior recipient prompt before a peer footer, and different source/target flavors.

HAPI Bot

Comment thread hub/src/sync/workGraphNotifyIngest.ts Outdated
Comment thread hub/src/sync/workGraphNotifyIngest.ts Outdated
Comment thread hub/src/sync/syncEngine.ts Outdated
Elevate peer user footers only when SyncEngine passes
trustedPeerSourceSessionId from the CLI peer route. Skip recipient cause
attribution on peer rows and tag flavor from the source session.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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.

Findings

  • [Minor] Preserve the peer origin in sentFrom — the new route stores an attributed peer delivery as webapp, but later work-ad cause stamping reads meta.sentFrom; a recipient agent's response is therefore recorded with causeKind: "webapp" instead of "peer". Evidence: hub/src/web/routes/cli.ts:328; related handling: hub/src/sync/workGraphNotifyIngest.ts:351.

    Suggested fix:

    // MessageService/SyncEngine payload type
    sentFrom?: 'telegram-bot' | 'webapp' | 'peer'
    
    // peer route
    await engine.sendMessage(target.sessionId, {
        text: parsed.data.text,
        sentFrom: 'peer',
        notifySource: 'peer',
        peerSourceSessionId: source.session.id
    })

Questions

  • None.

Summary

Review mode: follow-up after new commits

One Minor attribution issue remains in the latest full diff. The three findings from the prior HAPI Bot review are addressed by the new commit.

Testing

  • Not run (automation; PR code was not executed).
  • Static check: git diff --check passed.
  • GitHub checks observed: drift-gate and integration passed; test was pending at review time.
  • Add a route-level regression asserting the persisted peer message uses sentFrom: "peer", plus a recipient-summary assertion for causeKind: "peer".

HAPI Bot

Comment thread hub/src/web/routes/cli.ts Outdated
Keep work-ad causeKind as peer for recipient assistant summaries that
cite an attributed peer handoff.

Co-authored-by: Cursor <cursoragent@cursor.com>

@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.

Findings

  • None.

Questions

  • None.

Summary

Review mode: follow-up after new commits

No reportable issues found in the latest full diff. The latest commit resolves the prior sentFrom / causeKind mismatch. Residual risk: the dedicated /cli/sessions/:id/peer-messages route has no direct route-level regression coverage; the added tests exercise the ping-peer client and SyncEngine ingest separately, so route authentication, namespace checks, self-target rejection, inactive-target rejection, and persisted peer origin are not covered together.

Testing

  • Not run (automation; PR code was not executed).
  • Static check: git diff --check origin/main...HEAD passed.
  • GitHub checks: drift-gate, test, and integration passed.
  • Suggested coverage: route-level success asserting sentFrom: "peer", plus cross-namespace, self-target, and inactive-target rejection cases.

HAPI Bot

@heavygee heavygee added area:cli CLI, runner, agent wrappers area:hub Hub server (API, sync, store) bug Something isn't working labels Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:cli CLI, runner, agent wrappers area:hub Hub server (API, sync, store) bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(hub): AGENT_NOTIFY_SUMMARY on peer user-role deliveries skipped by work-graph ingest

1 participant