Inbox Digests And Threaded Notification Entries

Summary

AgentInbox should reduce bursty notification noise without hiding or destroying the original source events.

The current activation buffer can coalesce bursts into fewer dispatches, but it still leaves the runtime with the same core problem:

This RFC proposes a two-layer model:

In this model:

Problem

For bursty sources such as GitHub PR reviews or CI updates, one logical unit of work often arrives as many raw events:

Today AgentInbox already batches activation dispatches using a service-level window and max-items threshold, but that only reduces activation frequency. It does not change the agent-facing read surface.

That means the runtime still has to:

  1. receive an activation
  2. read raw inbox items
  3. discover that many items are about the same object
  4. re-aggregate them in runtime logic

This pushes grouping semantics back into the runtime, which conflicts with the product boundary.

Goals

Non-Goals

Design Principles

1. Raw Facts Stay Raw

InboxItem remains the immutable, durable record of what the source produced.

Aggregation should not mutate or collapse the fact layer. It should build an agent-facing read model on top of it.

2. Read Units Must Be Stable

If a runtime reads a digest and later acks it, the meaning of that ack must not change underneath it.

That means:

3. Grouping Belongs At The Inbox Presentation Boundary

Grouping should happen:

It should not live:

4. Source Semantics At The Edge

The core should not try to infer too much provider-specific meaning.

Source or module implementations should be able to supply:

The core can provide a conservative fallback, but high-quality grouping should come from the source edge.

Current State

Today the relevant pieces are:

The existing activation buffer:

So it solves “fewer wakeups” but not “fewer things to read”.

Core Design

1. Add An Agent-Facing InboxEntry Read Model

InboxItem remains the fact layer.

On top of it, AgentInbox should introduce a new agent-facing read model:

An InboxEntry is what runtimes read, what activations reference, and what ack targets by default.

Suggested entry kinds:

This means the runtime-facing mailbox becomes a presentation model, not just a raw event table.

2. Introduce DigestThread As The Logical Group

Some related events should continue to accumulate into one logical thread as long as that thread remains active and unacked.

Suggested internal object:

Responsibilities:

DigestThread is mutable and long-lived.

It is not the object that runtimes ack directly.

3. Materialize Immutable DigestSnapshot Entries

Because a live thread can change over time, runtimes should never ack the live thread directly.

Instead, each visible grouped entry should be an immutable snapshot of a thread at a particular revision.

Suggested object:

Suggested fields:

Behavior:

This is what keeps read/ack semantics stable.

4. Grouping Is Keyed By Resource + Event Family

Grouping should be driven by a source/module-provided grouping hint.

Suggested minimum group key components:

Examples:

Default fallback should be conservative, for example:

If a source cannot provide useful grouping semantics, AgentInbox should prefer under-grouping over incorrect grouping.

5. Aggregation Policy Belongs To The Agent-Facing Inbox Layer

Because grouped entries are part of the shared read model for an agent inbox, aggregation policy should not be target-specific.

That is an important boundary:

Suggested placement:

Phase 1 suggested policy shape:

type InboxAggregationPolicy = {
  enabled?: boolean;
  windowMs?: number;
  maxItems?: number;
  maxThreadAgeMs?: number;
};

This is separate from:

6. Two-Stage Aggregation: Burst Buffer + Unacked Thread Merge

Pure time-window grouping is not enough.

If grouping only happens within a short flush window, it solves simultaneous bursts but not long-lived notification threads like social-network notifications.

So the model should have two stages:

Stage A: Burst Buffer

New raw items enter a short pending aggregation buffer keyed by the grouping key.

This prevents re-materializing the thread on every single event in a burst.

Stage B: Unacked Thread Merge

When the burst buffer flushes:

This is what allows cross-time grouping while still keeping stable snapshots.

7. Flush Rules

The pending burst buffer requests a flush when any of these happen:

maxItems counts unacked items that have never appeared in a committed snapshot of that thread, not its entire unacked backlog. Every snapshot still contains the full unacked read set; materialization is not consumption or ack. Historical snapshot membership survives superseding and GC while the raw item is retained, so restart or partial ack does not turn old items into a new burst.

Both size-triggered and timer-triggered normal flushes consult the source's shouldFlushDigestThread gate. A forced sweep ignores the due time, not the source gate. A denied request preserves pending items and schedules a recheck. Explicit immediate hints retain their bypass semantics; a normal size threshold is not an immediate hint.

Flush attempts for the same inbox/group key are serialized across asynchronous source hooks. Before committing, the service revalidates the candidate read set. Items arriving during a hook remain pending with a future deadline rather than being lost when an older snapshot commits. Ack reconciliation is a separate read-model repair path, not permission to publish previously unmaterialized items or consume a source notification phase.

The logical thread itself should roll over and start a new thread when:

8. Source/Module Hooks

RemoteSourceModule should be able to provide optional grouping hooks.

Suggested hook shape:

deriveNotificationGrouping?(item, source, subscription) => {
  groupable: boolean;
  resourceRef?: string | null;
  eventFamily?: string | null;
  summaryHint?: string | null;
  flushClass?: "normal" | "immediate";
  evaluateOnAppend?: boolean;
}

Later, modules may also provide:

summarizeDigestThread?(items, context) => string | null

Business notification eligibility

A module can request evaluation on every append without bypassing its gate. The gate receives optional opaque notificationState and can return an updated state plus an explicit summary when granting a flush. The service persists that state atomically with the snapshot, scoped to inbox/group key and independent of thread rollover or raw-item ack. A failed materialization must not advance the module's notification boundary. The core does not interpret provider-specific phases or status enums.

The GitHub CI module uses this contract for workflow-run notifications:

The declared set defines this source's success barrier. It does not discover every possible conditional workflow or third-party check, and workflow-run notification is not per-job real-time monitoring. Existing sources without a declared set use the conservative incomplete-timeout path, not the former observed-runs-only success inference.

Business eligibility commits a readable snapshot, not successful delivery. Existing activation leases may defer a notification: it remains dirty pending state and retries at ack/lease expiry, including after daemon restart.

This keeps source semantics at the edge:

9. Identifier Strategy

The new aggregation-layer objects should optimize for local operator and runtime interaction, not for globally opaque UUID-style identifiers.

For this RFC, the recommended strategy is:

Suggested agent-facing refs:

Examples:

This keeps the storage model simple and the agent-facing surface compact:

The database should still keep ordering concerns separate from identity.

Recommended shape:

Where:

If phase 1 wants to keep things even simpler, it is acceptable for entry_id == sequence, but the conceptual model should still treat:

Formatting and parsing the typed refs should happen at the boundary layer. The database itself does not need to store literal strings like ent_42.

Read And Ack Semantics

1. Read Returns Stable Entries

Default inbox read should return InboxEntry[], not just raw inbox items.

For grouped bursts, the runtime sees one digest_snapshot entry instead of many related raw items.

If the runtime wants details, it can explicitly expand a digest snapshot into its referenced raw itemIds.

2. Ack Targets Immutable Snapshots

Ack should continue to support batch semantics, but the ack object should be a stable entry boundary.

Recommended behavior:

For digest snapshots:

This preserves stable boundaries even when the underlying digest thread keeps growing.

3. Superseded Snapshots Stay Valid Ack Boundaries

If a digest thread receives more items and materializes a newer snapshot:

This avoids the “read/ack window changed underneath me” problem.

Activation Semantics

Activation should target InboxEntry units, not raw inbox item batches.

That means:

This keeps activation, read, and ack aligned on the same agent-facing object.

Compatibility

Phase 1 does not need to remove the raw-item APIs immediately.

A safe rollout path is:

  1. keep raw InboxItem storage and internal behavior unchanged
  2. add InboxEntry and digest thread materialization alongside it
  3. make runtime-facing inbox read default to entries
  4. keep an explicit raw-item read path for debugging, replay, and low-level tooling

This preserves operability while shifting the default experience to grouped reads.

Observability

Aggregation must be observable.

Suggested metrics/events:

Without these, grouping bugs will be difficult to debug.

Why Not Only Activation-Level Aggregation

An earlier, simpler option would have been:

This is insufficient because it only reduces wakeups, not runtime work.

The runtime would still need to:

That would move grouping semantics back into runtime logic, which is not the intended boundary.

Why Not Replace Raw Items Entirely

Replacing raw items with only aggregate records would make:

So raw InboxItem records must remain the durable fact layer.

Phased Rollout

Phase 1

Phase 2

Phase 3

Open Questions

Conclusion

AgentInbox should not stop at reducing activation frequency.

To actually lower runtime overhead, it needs an agent-facing grouped read model that:

That gives AgentInbox a real inbox-level notification aggregation model, instead of leaving runtimes to rediscover grouping from raw events.