Context
Parent epic: #13889
A2A message ingestion needs the same local-vs-cloud topology discipline that the existing memory WAL drain already has. The parent incident is an acknowledged add_message write that returned a MESSAGE:* id but never durably landed in Nodes, GraphLog, or WAL. Grace added the operator parity constraint on #13889, and the epic-review closeout matrix now treats parity as a required row.
This child exists to make that parity deliverable explicit instead of leaving it as review prose.
Release classification: boardless - data-integrity hardening under an active epic, but not currently attached to a release board.
Live latest-open sweep: checked the latest 20 open issues immediately before creation on 2026-06-22T23:06Z; no equivalent child ticket found. Scope-specific live sweep for message WAL + local + cloud + drain returned only parent epic #13889. A2A in-flight sweep checked the latest 30 messages and found no competing lane-claim for this child scope.
The Problem
A WAL-first message path can still fail the parent epic if its drain only works in one deployment reality. Local maintainer setups use native processes and repo-local .neo-ai-data, while cloud deployments use separate containers for Memory Core, model serving, and vector storage. If the message drain invents its own placement, config leaves, lock behavior, or volume/reachability assumptions, it can pass locally while failing in the cloud stack, or the reverse.
The existing memory WAL drain already solved this topology split. Message ingestion should mirror that shape, not create a parallel one.
The Architectural Reality
The current memory WAL drain documents two host modes in ai/daemons/embed/drainCycle.mjs:248: supervised daemon process for the local/orchestrator-managed profile, and memoryWal.inProcessDrain for containerized/single-process deployments.
ai/mcp/server/memory-core/Server.mjs:251 hosts the in-process drain loop inside the Memory Core server when configured, while continuing to serve if another host already owns the drain lock.
ai/daemons/embed/drainLock.mjs:1 owns the per-WAL-directory .drain-lock contract and fail-loud refusal when a second live host attempts to drain the same WAL directory.
ai/mcp/server/memory-core/config.template.mjs:371 through ai/mcp/server/memory-core/config.template.mjs:424 define the memory WAL daemon config leaves and NEO_MEMORY_WAL_IN_PROCESS_DRAIN host-mode toggle.
The Fix
Define and implement the A2A message-ingestion drain topology by mirroring the memory WAL host-mode contract:
- Local profile: a supervised message-ingestion drain host with daemon data/log/pid state comparable to the memory embed daemon.
- Containerized/single-process profile: an in-process Memory Core drain mode for message WAL when no external orchestrator-supervised daemon exists.
- Mutual exclusion: exactly one message WAL drain host per message WAL directory, with a fail-loud lock/refusal path comparable to the existing
.drain-lock discipline.
- Config overlay: template leaves and env overrides sufficient for both local and cloud deployments, with stale-overlay diagnostics comparable to memory WAL leaves.
- Deployment wiring: document or adjust the cloud/container profile so the message WAL drain can reach the WAL, graph, and embedding/vector surfaces with the same topology assumptions as memory WAL.
Do not implement the add_message WAL append contract here unless it is a small prerequisite; that belongs in a separate child if it grows beyond this topology leaf.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
| Message WAL drain host mode |
memoryWal drain topology in drainCycle.mjs and Server.mjs |
Message drain supports local supervised host and containerized in-process host |
Fail loud if the configured host mode cannot start safely |
Config/template comments and ticket/PR body |
L2 unit/config tests plus L3 smoke or declared cloud residual |
| Message WAL drain lock |
drainLock.mjs sole-drainer invariant |
A second live message drain host refuses to start or logs loud without double-draining |
Stale dead-holder lock can be reclaimed |
JSDoc on lock helper or reuse docs |
L2 lock/refusal tests |
| Message drain config leaves |
config.template.mjs memoryWal leaves |
Env-backed leaves cover directory, cadence, batch/retry, data dir, and in-process mode as needed |
Stale overlay gets actionable diagnostics |
Config template comments |
L1 static check plus L2 startup/config test |
| Cloud/container wiring |
parent epic parity note and existing Memory Core deployment shape |
Cloud deployments can run exactly one reachable message drain host |
Operator-declared residual if real cloud smoke is outside CI |
PR body Evidence line and deployment docs if touched |
L3 smoke or explicit residual |
Decision Record impact
Aligned with the existing memory WAL drain substrate. No ADR change expected for this child.
Decision Record: Not needed.
Acceptance Criteria
Out of Scope
- The core WAL-first
add_message acceptance contract, unless a tiny seam is required to wire topology tests.
- Full graph replay/idempotency semantics for every MESSAGE edge type.
- A2A message semantic-search UX beyond declaring whether it is handled or deferred to
#10150.
- The separate fade/apoptosis MESSAGE-exemption hardening noted on the parent epic.
Avoided Traps
- Do not create a message-specific deployment topology that diverges from memory WAL without explicit rationale.
- Do not make local success the only evidence; cloud/container parity is the point of this leaf.
- Do not reintroduce synchronous model calls in
add_message to satisfy embedding/search convenience.
- Do not allow a double-drain race; marker/replay semantics need a single owner per WAL directory.
Related
Parent: #13889
Related: #10150
Origin Session ID: db5b2ecf-db91-4b7d-9498-ccef00426a1c
Handoff Retrieval Hints: A2A message ingestion daemon local cloud parity memoryWal drain topology; message WAL drain host mode inProcessDrain sole-drainer lock
Context
Parent epic: #13889
A2A message ingestion needs the same local-vs-cloud topology discipline that the existing memory WAL drain already has. The parent incident is an acknowledged
add_messagewrite that returned aMESSAGE:*id but never durably landed in Nodes, GraphLog, or WAL. Grace added the operator parity constraint on#13889, and the epic-review closeout matrix now treats parity as a required row.This child exists to make that parity deliverable explicit instead of leaving it as review prose.
Release classification: boardless - data-integrity hardening under an active epic, but not currently attached to a release board.
Live latest-open sweep: checked the latest 20 open issues immediately before creation on 2026-06-22T23:06Z; no equivalent child ticket found. Scope-specific live sweep for
message WAL+local+cloud+drainreturned only parent epic#13889. A2A in-flight sweep checked the latest 30 messages and found no competing lane-claim for this child scope.The Problem
A WAL-first message path can still fail the parent epic if its drain only works in one deployment reality. Local maintainer setups use native processes and repo-local
.neo-ai-data, while cloud deployments use separate containers for Memory Core, model serving, and vector storage. If the message drain invents its own placement, config leaves, lock behavior, or volume/reachability assumptions, it can pass locally while failing in the cloud stack, or the reverse.The existing memory WAL drain already solved this topology split. Message ingestion should mirror that shape, not create a parallel one.
The Architectural Reality
The current memory WAL drain documents two host modes in
ai/daemons/embed/drainCycle.mjs:248: supervised daemon process for the local/orchestrator-managed profile, andmemoryWal.inProcessDrainfor containerized/single-process deployments.ai/mcp/server/memory-core/Server.mjs:251hosts the in-process drain loop inside the Memory Core server when configured, while continuing to serve if another host already owns the drain lock.ai/daemons/embed/drainLock.mjs:1owns the per-WAL-directory.drain-lockcontract and fail-loud refusal when a second live host attempts to drain the same WAL directory.ai/mcp/server/memory-core/config.template.mjs:371throughai/mcp/server/memory-core/config.template.mjs:424define the memory WAL daemon config leaves andNEO_MEMORY_WAL_IN_PROCESS_DRAINhost-mode toggle.The Fix
Define and implement the A2A message-ingestion drain topology by mirroring the memory WAL host-mode contract:
.drain-lockdiscipline.Do not implement the
add_messageWAL append contract here unless it is a small prerequisite; that belongs in a separate child if it grows beyond this topology leaf.Contract Ledger Matrix
drainCycle.mjsandServer.mjsdrainLock.mjssole-drainer invariantconfig.template.mjsmemoryWal leavesDecision Record impact
Aligned with the existing memory WAL drain substrate. No ADR change expected for this child.
Decision Record: Not needed.
Acceptance Criteria
#10150.add_messageremains free of synchronous chat/embed/model calls after this topology work; any model-dependent ingestion stays daemon/drain-owned.Out of Scope
add_messageacceptance contract, unless a tiny seam is required to wire topology tests.#10150.Avoided Traps
add_messageto satisfy embedding/search convenience.Related
Parent: #13889
Related: #10150
Origin Session ID: db5b2ecf-db91-4b7d-9498-ccef00426a1c
Handoff Retrieval Hints:
A2A message ingestion daemon local cloud parity memoryWal drain topology;message WAL drain host mode inProcessDrain sole-drainer lock