| Field | Value |
|---|---|
| RFC | 0057 |
| Title | An optional memory.attribution capability + additive memory.written RunEvent carrying { memoryRef, memoryId, nodeId?, agentId? }, so a consumer can see which node wrote which memory entry during a run — making per-node memory provenance observable on the wire without exposing memory content |
| Status | Accepted |
| Author(s) | David Tufts (@davidscotttufts) |
| Created | 2026-05-25 |
| Updated | 2026-05-26 (Active → Accepted — non-steward host advertises it: MyndHyve workflow-runtime advertises capabilities.memory.attribution.{supported: true, emitsWriteEvents: true} live on https://workflow-runtime-gjw5bcse7a-uc.a.run.app/.well-known/openwop (openwop-side curl-verified 2026-05-26, revision workflow-runtime-00217-q7c) and dual-emits the canonical content-free memory.written (honoring memory-attribution-no-content) alongside a vendor x-host-myndhyve-memory-written variant retained for MAE-3 reverse-projection. Honest omission: the SHOULD-tier nodeId field is not yet threaded through MyndHyve's ServerMemoryWriteInput — permitted by RFC 0057 §B ("writes with no node attribution"), not blocking. 2026-05-25 (Draft → Active).) |
| Affects | schemas/capabilities.schema.json (additive memory.attribution block) · schemas/run-event.schema.json (additive memory.written event type) · spec/v1/agent-memory.md (write-attribution as an observability surface; closes an Open spec gap) · spec/v1/observability.md (event vocabulary row) · spec/v1/replay.md (the event is a recorded fact, re-emitted from the log) · SECURITY/invariants.yaml (no-content / SR-1 + CTI-1 invariants) · new conformance scenarios |
| Compatibility | additive |
| Supersedes | — |
| Superseded by | — |
Summary
OpenWOP exposes memory as a read-only adapter (agent-memory.md — list / get), but the run event log carries no signal for which node wrote which memory entry. The only memory event today is memory.compacted (RFC 0012), which reports a host-managed compaction, not per-node provenance. This RFC adds an optional memory.attribution capability and an additive memory.written RunEvent emitted when a node's execution causes a memory write, carrying identifiers only — { memoryRef, memoryId, nodeId?, agentId?, tags? }, never the entry content. A debugger, a timeline view, or an analytics consumer can then attribute memory provenance to the node that produced it (and correlate to the entry via the existing read-side), turning today's flat "ledger" into a per-node memory trail. Everything is advertisement-gated; the event is ignorable by existing consumers; and because the payload is content-free, it adds no BYOK-leakage or replay-divergence surface.
Motivation
Three concrete needs, none expressible portably today:
1. Per-node memory provenance in debugging. A reference-app memory ledger (app-ux §A3) can list a tenant's memory entries via the read-side, but it cannot say _"node summarize-2 wrote this entry."_ The wire carries no attribution, so a "which node touched memory" timeline marker is impossible — agent-memory.md §"Open spec gaps" and observability.md are both silent on per-node memory events. Every host that wants this invents an incompatible, app-private signal. 2. Honest analytics. "How much memory did this run produce, and where?" is unanswerable from the event log. Memory writes are the durable side effect of a run; not emitting them leaves a blind spot next to cost, reasoning, and interrupt events that _are_ attributed. 3. Replayable provenance. When a run is forked/replayed, the reviewer wants to see the memory writes the original run made, attributed to nodes, as recorded facts — not regenerated guesses.
This is squarely an observability concern (observability.md), the same domain that justifies agent.reasoned / agent.toolCalled. The interop argument mirrors those: a memory-provenance signal is only useful if any consumer reads it identically. A host-private "node→memory" table fails that test; a memory.written event passes it.
Why the spec was silent until now (and why this is safe). agent-memory.md deliberately keeps memory _access_ internal to nodes and emits no per-access events, to protect replay determinism (a mid-run memory mutation that became observable could perturb a replay). This RFC does not reopen that: memory.written records a write that _already happened_ as an immutable event-log fact. On replay the event is re-read from the log, never regenerated — the host MUST NOT mint new memoryIds or timestamps at replay time. The payload is content-free, so it carries no non-deterministic body to diverge on. Reads remain unattributed (a memory.read event is explicitly out of scope — see Alternatives).
Proposal
A. Capability advertisement (additive)
schemas/capabilities.schema.json gains an additive memory.attribution sub-block:
"attribution": {
"type": "object",
"additionalProperties": false,
"properties": {
"supported": { "const": true },
"emitsWriteEvents": { "type": "boolean" }
},
"required": ["supported"]
}
A host advertising capabilities.memory.attribution.{ supported: true, emitsWriteEvents: true } MUST emit memory.written for every memory write it makes during a run (MUST). A host that omits the block, or sets emitsWriteEvents: false, MUST NOT be expected to emit it, and consumers MUST tolerate its absence (MUST). Existing hosts are unaffected — the block is absent and the event never appears.
B. The memory.written RunEvent (additive)
schemas/run-event.schema.json gains an additive event type. The payload carries identifiers only:
{
"type": "memory.written",
"payload": {
"memoryRef": "agent:summarizer:tenant-42",
"memoryId": "mem_8c1f0a2b",
"nodeId": "summarize-2",
"agentId": "summarizer",
"tags": ["session-summary"]
}
}
| Field | Required | Notes |
|---|---|---|
memoryRef | MUST | The ref the entry was written under (resolvable via the read-side). |
memoryId | MUST | Host-issued entry id, stable; correlates to MemoryAdapter.get(memoryRef, memoryId). |
nodeId | SHOULD | The node whose execution caused the write. Omitted only for writes with no node attribution (e.g. host session-end auto-memory). |
agentId | MAY | The agent identity (AgentRef) behind the write, when known. |
tags | MAY | The entry's tags (non-secret labels only). |
The event MUST NOT carry the entry content (the read-side serves it, already SR-1-redacted). It emits in the updates and debug stream modes (stream-modes.md).
C. SECURITY invariants
Two protocol-tier invariants in SECURITY/invariants.yaml, each with a public conformance test:
memory-attribution-no-content— amemory.writtenevent payload MUST NOT contain the memory entry content or any BYOK-resolved secret material (SR-1 holds trivially: identifiers + non-secret tags only).memory-attribution-tenant-scoped—memory.writtenevents appear only on the run event stream of the tenant that owns the run (CTI-1; no cross-tenantmemoryRef/memoryIddisclosure).
D. Replay
Per replay.md: memory.written is an immutable recorded fact. On POST /v1/runs/{runId}:fork against a historical checkpoint, the host MUST re-emit the recorded events from the log and MUST NOT regenerate memoryId or timestamps. No new non-determinism is introduced (the payload is content-free).
The distinction is between the two fork modes. A branch-mode fork is a genuinely new run: it MAY perform its own memory writes and emit its own memory.written events with fresh memoryIds — those are new facts, not regenerated ones. A replay-mode fork reproduces a prior run's recorded history; the host MUST NOT mint a new memoryId for a write the source run already recorded.
Correction (2026-08-18). The implementation note that stood here was wrong, and
it is retired rather than edited, because it told implementers to do something the
normative text forbids.
It said the reference host "honors this by skipping its host session-end
run-summary write when
run.forkMode === 'replay'", and conceded in its own wordsthat suppressing "satisfies the
MUST NOT regeneratehalf". **Half is not therequirement.**
replay.md§"Determinism guarantees" caveat 5 (Stable, and unchangedsince v1.2) names this exact case — *"events whose payload records a write that
already happened, such as
memory.written(RFC 0057), are fixed history. On replayagainst a checkpoint a host MUST re-emit them from the event log and MUST NOT
regenerate their identifiers or timestamps"* — and RFC 0041 §C requires caching
"the observable result (return value, side-effects on workflow state, **emitted
events**)" so a replay reproduces the observable sequence. Three normative statements,
all re-emit; the note described a host meeting one of two obligations and read as
permission.
Nothing normative changed here — a suppressing host was always non-conformant.
What changed is that the suite can now see it:
memory-attribution-replay-stableasserted only "no new
memoryId", exactly the half a suppressing host satisfies, soa replay emitting nothing passed it vacuously. The MUST-re-emit half is asserted
as of suite 1.136.10. A replay whose log is short of the source's recorded
memory.writtenevents is not a reproduction of the observable sequence, which iswhat RFC 0041 §C byte-equivalence means.
Found by a tier-2 host that implemented "skip" from this note, and by the tier-1 host
that implemented re-emit from the normative text — the two diverged for months while
both stayed green.
Classification (added 2026-08-18, after review): this is not a tidy-up. Under the
W3C Process taxonomy it is a Class 3 change — *"corrections that MAY affect
conformance, but add no new features"*, whose definition covers a change that "clears
up an ambiguity or under-specified part of the specification in such a way that an
agent whose conformance was once unclear becomes clearly conforming or non-conforming".
That is this fact pattern exactly. It is not Class 2 (*"editorial changes or
clarifications that do not change the technical content"*), even though the technical
content genuinely did not move — because what moves is **which implementations can
claim conformance**. Under the IETF axis the same change reads Technical rather than
Editorial, but verified rather than requiring a
-bis, since theMUSTneverchanged; the two taxonomies do not map 1:1 and are recorded separately rather than
collapsed.
**Practical consequence, stated plainly so an operator does not have to infer it: a
host that suppresses instead of re-emitting is now clearly non-conforming, where
before its status was arguable.** No host is newly broken — the requirement is
unchanged and both known hosts re-emit — but a suppressing implementation can no
longer read this note as cover. That is a different sentence from "documentation
cleanup" and warrants different urgency from whoever operates such a host.
Compatibility
Additive. New optional capability block + new optional event type. No existing required field changes; no existing event shape changes; no existing MUST relaxes. Hosts that don't advertise memory.attribution never emit the event, and consumers that don't recognize memory.written ignore it per the forward-compatibility rule (COMPATIBILITY.md §2.1). Existing v1.x conformance passes are unaffected.
Conformance
New capability-gated scenarios (gated on capabilities.memory.attribution.emitsWriteEvents):
- A run that writes memory emits a
memory.writtenevent whosememoryIdresolves viaMemoryAdapter.get. - The event payload contains no
contentfield (the no-content invariant). memory.writtenevents for run R appear only on R's tenant stream (the tenant-scoped invariant).- A host not advertising the capability emits no
memory.writtenand still passes the locked core.
Alternatives considered
- A
writtenByNodeIdfield onMemoryEntry. Rejected as primary: it conflates the durable entry (read-side) with run-time provenance, and a single entry mutated by multiple runs/nodes has no single writer. An event is the natural per-write record. (A host MAY still exposewrittenByNodeIdas a host extension.) - A
memory.readevent. Out of scope. Reads are high-volume and re-emitting attributed reads risks the very determinism concernagent-memory.mdavoided. Writes are the durable, attributable mutations; start there. - Carrying content in the event. Rejected — it duplicates the read-side and reopens the SR-1 redaction surface on the event log. Identifiers + a read-side fetch keep the event content-free.
Unresolved questions
- Should
memory.writtencarry acontentDigest(non-reversible hash) for change-detection without a read-side round-trip? Deferred — adds a hashing-recipe normative surface; revisit on implementer demand. - Batch writes: one event per entry vs. one event per write-batch. Proposed: one event per entry (simplest to correlate); a
batchIdcould group them additively later.
Acceptance criteria
Promotion Draft → Active when the schema additions (capability block + event type), the agent-memory.md / observability.md prose, the two SECURITY invariants + public tests, and the capability-gated conformance scenarios land atomically and a host advertises capabilities.memory.attribution.emitsWriteEvents: true with honored behavior. Active → Accepted on a non-steward host advertising it, per RFCS/0001 §"Promotion to Accepted".
References
spec/v1/agent-memory.md§"Open spec gaps" (the gap this closes)spec/v1/observability.md§event vocabulary (memory.compactedprecedent)spec/v1/replay.md(recorded-fact re-emission)schemas/run-event.schema.json·schemas/capabilities.schema.json·schemas/memory-entry.schema.jsonRFCS/0004-memory-layer.md(the read-side this attributes)RFCS/0012(memory.compacted, the only prior memory event)