Skip to content

docs(adr): ADR-012 memory propagation + event-payload injection - #276

Merged
samxu01 merged 2 commits into
mainfrom
docs/adr-012-memory-propagation
May 3, 2026
Merged

docs(adr): ADR-012 memory propagation + event-payload injection#276
samxu01 merged 2 commits into
mainfrom
docs/adr-012-memory-propagation

Conversation

@samxu01

@samxu01 samxu01 commented May 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Builds on ADR-003. Closes the cross-session-context gap that surfaced when agent-dm shipped (#275 / d5b7198e3c): an agent's session in agent-dm pod-A doesn't see what its sibling session in team pod-B did.

Two halves, both kernel-side, both runtime-agnostic:

1. New typed system_exchanges section in the ADR-003 envelope. Structured entries (kind, surfacePodId, peers, takeaway), not a markdown blob. Section is read-only from the agent's perspective — the name isn't in commonly_save_my_memory's writable enum, so write-rejection is structural rather than markdown-parsing-based. Visibility is hard-coded 'private' at the schema level, preserving ADR-003's agent-isolation rule even though the platform dual-writes to both peers in an exchange.

2. Two new fields on the existing CAP event payload schemamemoryRevision + memoryDigest. No new HTTP verbs, no new tool surface. Every runtime that reads its event payload gets memory propagation for free.

What's NOT in the ADR (deliberate)

  • ❌ LLM-side summarization on write (verbatim metadata in v1; v2 may layer condense step).
  • ❌ Cross-agent memory reads (ADR-003 §Visibility still owns; cross-agent context flows through commonly_ask_agent, not memory peeks).
  • ❌ Admin viewer for system_exchanges (would bypass agent-isolation; needs a privacy-vs-audit ADR addendum first).
  • ❌ EventHandler-as-Installable framing for the four triggers (v1 hardcodes them as platform hooks because Installable Phase 3 hasn't shipped the read-path switch and third-party handlers writing to system_exchanges would re-open the privacy hole).
  • ❌ Cross-pod-mention trigger (too noisy in multi-agent team pods; dropped for v1, v1.x reconsider with strict non-member-pod filter).

Self-review pass

Ran the code-reviewer agent on a v0 draft. Verdict was "request changes." Two critical architectural issues, four important ones, three real questions. All addressed before this PR:

Reviewer finding Fix in this draft
Critical: v0 built on the deprecated content blob; ADR-003 moved to typed sections Retargeted entirely to the sections envelope. Section-level ACL replaces markdown parsing.
Critical: v0 claimed ack idempotency that agentEventService.acknowledge doesn't actually have Replaced claim with a real spec: status-gated `findOneAndUpdate` + monotone `$max`. Implementation lands in Phase 2.
Important: cross-pod-mention trigger would flood the cap in multi-agent pods Dropped for v1. v1.x reconsider with non-member-pod filter.
Important: "drivers MUST surface memoryDigest" vs "adoption is independent" — pick one SHOULD not MUST. Un-adopted runtimes degrade (lazy reads via tool call) — degraded, not broken.
Important: Notes/System ownership unenforceable on PUT /memory Structural section solves this naturally — agent's tool can't address system_exchanges.
Important: Phase-1 estimate too low if structural-section migration is in scope Honest 3–4 days for Phase 1; total 5.5–6.5d for Phases 1–3.
Question: count-vs-time eviction Hard decision: count-bounded (50 entries). v1.x may layer hybrid.
Question: ADR-003 invariant 8 dedup invalidation New carve-out (invariant 8a): system writes don't touch `lastSyncKey`.
Question: should triggers be EventHandler Installables? Documented in Non-goals: v1 hardcodes; v2 may revisit.
Nit: digest size estimate too optimistic Cap is 1.5KB serialized OR 5 entries, whichever hits first.
Nit: "new CAP verb" framing was misleading Reworded — two new fields on existing payload, zero new verbs.

Test plan

  • Read ADR end-to-end. The §3 Acknowledgement-semantics block is the load-bearing implementation contract — sanity-check it against agentEventService.ts.
  • Confirm the §1 `SystemExchangesSection` interface composes cleanly with ADR-003's `MemorySection` shape.
  • Open Questions section has 3 items, all of which are post-v1 reconsider triggers (not blocking decisions). Agree?
  • Phasing: Phase 1 (3–4d) + Phase 2 (2d) + Phase 3 (½d) + Phase 4 (per-runtime, parallel) — buy or push back?

🤖 Generated with Claude Code

…s + payload injection

Builds on ADR-003 (Memory as Kernel Primitive). Closes the
cross-session-context gap that landed when agent-dm shipped
(d5b7198): a session in agent-dm pod-A doesn't see the sibling
session's work in team pod-B.

Two halves, both kernel-side, both runtime-agnostic:

1. New typed `system_exchanges` section in the ADR-003 envelope —
   structured entries (kind, surfacePodId, peers, takeaway) instead
   of a markdown blob. Section is read-only from the agent's
   perspective; the section name isn't in `commonly_save_my_memory`
   writable enum, so write-rejection is structural rather than
   markdown-parsing-based.

2. Two new fields on the existing CAP event payload schema —
   `memoryRevision` + `memoryDigest`. No new HTTP verbs. Every
   runtime that reads its event payload gets memory propagation
   for free.

Self-review pass via code-reviewer agent caught two architectural
issues, both addressed before this draft:

- v0 was building on the deprecated `content` blob from ADR-003.
  Retargeted to the typed `sections` envelope that ADR-003
  established as the v2 schema.
- v0 claimed ack-time `lastSeenRevision` bumps were idempotent,
  but `agentEventService.acknowledge` has no idempotency guard
  today. Replaced the claim with a real spec: status-gated
  `findOneAndUpdate` + monotone `$max` bump, so dup acks fire the
  bump at most once and out-of-order acks still converge.

Hard decisions made (not punted to Open Questions):

- Cross-pod-mention trigger DROPPED for v1 — too noisy in
  multi-agent team pods. v1.x reconsider with strict non-member
  filter.
- Eviction is COUNT-bounded (50 entries), not time-bounded.
  Time-based loses important low-frequency entries. v1.x may
  layer hybrid.
- NO admin/operator viewer for `system_exchanges` in v1 without an
  explicit privacy-vs-audit ADR addendum. Dual-write-to-both-peers
  preserves agent isolation only if no one bypasses the rule.
- Driver-side adoption is SHOULD not MUST. Agents on un-adopted
  runtimes operate in degraded mode (lazy reads via tool call) —
  degraded, not broken.
- ADR-003 invariant 8 (cross-writer dedup invalidation) gets a
  carve-out (8a) for system writes — they don't touch
  `lastSyncKey`, so driver-side sync dedup stays correct.

Phase-1 estimate honest at 3-4 days (was 2 in v0; structural
section migration adds work). Total v1 ETA 5.5-6.5 days for
Phases 1-3, plus per-runtime adoption (parallelizable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@samxu01 samxu01 left a comment

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.

Verified the load-bearing technical claims against the codebase — acknowledge lacks idempotency as described (agentEventService.ts:828–890), the sections envelope shape composes cleanly, and the enqueueDmEvent / bot_loop_guard / tasksApi complete-handler callsites all exist. Spec is in good shape.

Six inline suggestions, ordered roughly by leverage:

  1. Phase pointer for the new tool-section row is wrong (line 85) — needs to land in Phase 1, not Phase 4, or the lazy-read fallback doesn't work day one.
  2. Writable-section enum is asserted as if pre-existing (line 91) — it isn't; Phase 1 needs to add it.
  3. memoryRevisionAtDelivery "captured at fetch-time" is underspecified (line 124) — the polling path is non-mutating today; persisting this field requires a real semantic change to list(). Pick a model.
  4. Sizing math is internally inconsistent (line 110) — 5 fully-populated entries blow the 1.5KB cap.
  5. takeaway derivation is hand-wavy (line 161) — make the rule concrete for multi-turn / multi-paragraph cases.
  6. Cap enforcement needs the atomic Mongo idiom (line 167) — concurrent triggers will lost-update under read-modify-write.

None block merging this as Proposed; (1)–(3) are worth tightening before Phase 1 starts.


Generated by Claude Code

- **Typed reads** for the digest builder — no string slicing.
- **Future LLM condensation** can rewrite `takeaway` per-entry without touching the rest of the envelope.

ADR-003 §"Tool surface" gets one row added (Phase 4 of this ADR; see §Phasing):

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.

Phasing pointer is wrong. This says the new commonly_read_my_memory(section: 'system_exchanges') row lands in "Phase 4 of this ADR," but Phase 4 in §Phasing is "Driver-side adoption" — it never mentions extending the tool's section enum. The lazy-read fallback you describe in §3 ("agents that do neither — bump fires lazily when they read memory") doesn't function until the enum accepts 'system_exchanges', so this needs to land in Phase 1 alongside the schema field. Either move the row to Phase 1 here, or add an "enum extension" bullet to Phase 4.


Generated by Claude Code

|---|---|
| `commonly_read_my_memory(section: 'system_exchanges')` | Returns the structured entries. Read-only; agent cannot write. |

The existing `commonly_save_my_memory(section, content, ...)` tool **rejects** writes to `system_exchanges` at the API layer — section name is not in the writable enum.

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.

Writable-section enum is proposed, not pre-existing. Phrasing reads as if commonly_save_my_memory already enforces a section enum at the API layer — I couldn't find one in the current backend (no writableSections / explicit allow-list in agentMemoryService.ts). Phase 1 needs to add this enforcement explicitly, not rely on the description as if the structural rejection is already in place. Suggest reword: "...is rejected at the API layer; Phase 1 adds the writable-section allow-list (long_term, dedup_state, shared, daily, relationships) that excludes system_exchanges."


Generated by Claude Code

Backend tracks `AgentMemory.lastSeenRevision: number`. Bumping is **idempotent by construction** (see §Acknowledgement semantics below). `memoryDigest` returns entries with `revision > lastSeenRevision`, capped at 1.5KB total serialized JSON.

Sizing notes:
- Default cap: **1.5KB serialized digest, OR 5 entries, whichever hits first**. Byte cap dominates for noisy bursts; entry cap dominates for steady state.

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.

Sizing math is tight, possibly inconsistent. Each entry: ts (~24B) + kind (~25B) + surfacePodId (24B) + surfaceLabel (~30B) + peers[] (~40B) + takeaway (≤280B) + JSON overhead ≈ 420–450 bytes. 5 fully-populated entries ≈ 2.1KB+, well above the 1.5KB cap. Either bump the byte cap to ~2.5KB or drop the "5 entries" half — claiming both is misleading because the byte cap will dominate every time the takeaway is non-trivial. Same applies to the "~600 tokens per event" estimate one line down — that's the cap, not steady state.


Generated by Claude Code

{
...,
status: 'pending' | 'delivered' | 'acked', // existing field; new terminal state
memoryRevisionAtDelivery: number | null, // captured at fetch-time

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.

"Captured at fetch-time" is underspecified — does the doc get mutated on list? The current polling path is agentEventService.list() (agentEventService.ts:796), which is a non-mutating find over status: 'pending'. To persist memoryRevisionAtDelivery on the doc you'd have to convert the list path to either a findAndModify-style atomic update or follow-up bulk write — that's a real semantic change to the polling contract (e.g., what happens when two pollers race the same event?).

Alternative: compute memoryRevisionAtDelivery as a per-response payload field that is NOT persisted on the doc — but then the ack path can't read it back from the doc, so the agent ack would need to echo the revision number, opening a tampering surface.

Pick one and spell out the implication in this section. This is the load-bearing detail for Phase 2.


Generated by Claude Code


| Trigger | Source | Recipients | Entry shape |
|---|---|---|---|
| `agent-dm-conclusion` | `agentMessageService.postMessage` when `content === 'NO_REPLY'` in an `agent-dm` pod | both peers | `kind: 'agent-dm-conclusion'`, takeaway = last non-NO_REPLY content from sender (truncated 280) |

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.

takeaway derivation is hand-wavy. "Last non-NO_REPLY content from sender" — for a 5-turn DM concluding on turn 5, is the takeaway turn-4 alone, or some condensation of turns 1–4? Multi-paragraph turn N — truncate from start, middle-elide, or take the first sentence? Suggest making it concrete in v1: "the immediately-preceding non-NO_REPLY message from the same sender, head-truncated to 280 chars (with suffix on truncation)." Anything fancier waits for v2 LLM-condense.


Generated by Claude Code


**Cross-pod-mention is dropped as a v1 trigger** (per reviewer §Important). Recording every mention in a multi-agent team pod fills the entry cap with structural noise faster than real DM exchanges. v1.x will reconsider it with a strict filter (only when the *target* agent is NOT in the source pod, i.e. genuinely new context). v1 ships without it.

Writes are append-only via `AgentMemoryService.appendSystemExchange(agent, instance, entry)`. Cap enforcement is on write — when adding entry N+1 past the cap, drop the oldest.

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.

Cap enforcement on write needs the Mongo idiom spelled out. Two concurrent appendSystemExchange calls in the same tick (e.g., a DM-conclusion firing while a task-completed lands for the same agent) will lost-update each other under naïve read-modify-write. Specify the atomic shape:

await AgentMemory.updateOne(
  { agentName, instanceId },
  {
    $push: {
      'sections.system_exchanges.entries': {
        $each: [entry],
        $position: 0,
        $slice: 50,
      },
    },
    $inc: { revision: 1 },
    $currentDate: { 'sections.system_exchanges.updatedAt': true },
  },
);

$push+$position: 0+$slice: 50 keeps the most-recent-first invariant atomically; $inc: { revision: 1 } is the monotone bump consumed by memoryDigest.


Generated by Claude Code

Six concrete findings from the inline pass, all addressed:

1. Phasing pointer for the `commonly_read_my_memory(section: 'system_exchanges')`
   tool row was wrong — said "Phase 4" but Phase 4 is driver adoption,
   doesn't touch the tool surface. The lazy-read fallback in §3 depends
   on this enum row; moved to Phase 1 so it lands with the schema.

2. Writable-section enum was phrased as if it pre-exists in
   agentMemoryService. It doesn't. Reworded to make Phase 1 add the
   allow-list explicitly (long_term, dedup_state, shared, daily,
   relationships, soul, runtime_meta — excluding system_exchanges)
   with a 403 + structured reason on rejection.

3. Sizing math was inconsistent — "1.5KB OR 5 entries whichever
   first" admits at most ~3 worst-case entries before hitting the byte
   cap. Replaced with hard 2.5KB byte cap, dropped the entry-count
   half. Per-entry footprint spelled out (~420-450B worst case).
   "~600 tokens" replaced with "up to ~750 at the cap, far less in
   steady state, gated on revision change."

4. "Captured at fetch-time" for memoryRevisionAtDelivery was
   underspecified — today's polling is non-mutating list(). Phase 2
   converts the polling path to atomic findOneAndUpdate that
   transitions pending→delivered AND captures memoryRevisionAtDelivery
   in the same operation. Two pollers race the same event, only one
   wins the transition + capture (matches existing at-most-once
   delivery contract). Documented the rejected alternative
   (response-only field + agent echoes on ack) and why — tampering
   surface.

5. takeaway derivation was hand-wavy. Specified concretely:
   "immediately-preceding non-NO_REPLY message from the same sender,
   head-truncated to 280 chars with `…` suffix." No multi-turn
   condensation in v1.

6. Cap enforcement on write needed the atomic Mongo idiom spelled
   out — concurrent appendSystemExchange calls would lost-update under
   naïve RMW. Spelled out the $push + $position:0 + $slice:50 + $inc
   shape so single-document atomicity serializes concurrent triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@samxu01
samxu01 merged commit b92af64 into main May 3, 2026
6 of 7 checks passed
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.

1 participant