Skip to content

Commit 00534e9

Browse files
phernandezclaude
andcommitted
feat(indexing): relay self-supersede on stale base + db_version provenance (#1589)
Two changes for the basic-memory-cloud #1589 collab persistence redesign: 1. Relay self-supersede: the accepted-note base-checksum precondition now accepts a stale base when BOTH the current accepted version's last_source and the incoming request source are collaboration_relay. A relay persist that times out client-side after committing leaves the relay's recorded base one version behind its own write; rejecting that wedges every subsequent store in a 409 loop (observed live in cloud production 2026-07-23: zero persists for 10+ minutes while the user typed). The relay's next snapshot always supersedes its own prior write - the live Y.Doc is the merge of everything the relay ever persisted. Foreign writers keep full guarded semantics; the deleted-entity 409 also stays. 2. db_version provenance on index results: RuntimeNoteObjectProvenance now parses bm-db-version alongside actor/source; it threads through IndexedFileLiveUpdatePlan, IndexFileJobResult, and IndexFileNoteLiveUpdatePlan (withheld for superseded content, like content_checksum) so cloud's index-completion live updates can carry the monotonic version. Consumers then decide echo-vs-out-of-band by version arithmetic instead of checksum guessing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wFpjrd126rVe6VisiYVDD
1 parent cf3a557 commit 00534e9

7 files changed

Lines changed: 145 additions & 3 deletions

File tree

src/basic_memory/indexing/accepted_note_mutation_runner.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
plan_accepted_note_write_change,
4242
)
4343
from basic_memory.runtime.note_move import normalize_note_move_destination_path
44+
from basic_memory.runtime.note_object_metadata import NOTE_SOURCE_COLLABORATION_RELAY
4445
from basic_memory.runtime.storage import (
4546
NoteExternalId,
4647
ProjectExternalId,
@@ -607,7 +608,23 @@ async def _run_accepted_note_update(
607608
request.base_checksum is not None
608609
and current_note_content.db_checksum != request.base_checksum
609610
):
610-
reject_stale_base_checksum(current_db_checksum=current_note_content.db_checksum)
611+
# Relay self-supersede (#1589): when the current accepted version was
612+
# itself written by the collaboration relay AND this request is the
613+
# relay again, a stale base can only mean a lost ack — a persist that
614+
# timed out client-side after committing here. The relay's next
615+
# snapshot always supersedes its own prior write (the live Y.Doc is
616+
# the merge of everything the relay ever persisted), so rejecting
617+
# would wedge every subsequent store against our own committed
618+
# version (2026-07-23 production incident). Foreign writers keep the
619+
# full guarded semantics.
620+
relay_self_supersede = (
621+
request.source == NOTE_SOURCE_COLLABORATION_RELAY
622+
and current_note_content.last_source == NOTE_SOURCE_COLLABORATION_RELAY
623+
)
624+
if not relay_self_supersede:
625+
reject_stale_base_checksum(
626+
current_db_checksum=current_note_content.db_checksum
627+
)
611628
try:
612629
prepared_write = await prepare_accepted_note_replace(
613630
preparer,

src/basic_memory/indexing/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ class IndexFileJobResult:
237237
actor_kind: str | None = None
238238
actor_name: str | None = None
239239
live_update_source: str | None = None
240+
# Accepted DB version mirrored on the trusted object metadata (#1589):
241+
# carried into note-level live updates so consumers can decide
242+
# echo-vs-out-of-band by version arithmetic. None when the metadata was
243+
# absent or failed checksum validation.
244+
db_version: int | None = None
240245
# True when the object carried our own bm-file-checksum metadata that no
241246
# longer matches what this job indexed: a newer own-stack write landed
242247
# mid-job, so this result describes superseded content (issue #1445).
@@ -310,6 +315,9 @@ class IndexFileNoteLiveUpdatePlan:
310315
actor_user_profile_id: str | None = None
311316
actor_kind: RuntimeNoteActorKind | None = None
312317
actor_name: RuntimeNoteActorName | None = None
318+
# Accepted DB version from trusted object metadata (#1589); None when the
319+
# metadata was absent or failed checksum validation.
320+
db_version: int | None = None
313321

314322

315323
DEFAULT_INDEX_FILE_NOTE_LIVE_UPDATE_SOURCE: RuntimeNoteChangeSource = "s3_webhook"
@@ -359,6 +367,7 @@ def index_file_job_result_from_indexed_file(
359367
live_update_source=(
360368
live_update_plan.live_update_source if live_update_plan is not None else None
361369
),
370+
db_version=live_update_plan.db_version if live_update_plan is not None else None,
362371
content_superseded=(
363372
live_update_plan.content_superseded if live_update_plan is not None else False
364373
),
@@ -436,6 +445,9 @@ def plan_index_file_note_live_update(
436445
actor_user_profile_id=result.actor_user_profile_id,
437446
actor_kind=result.actor_kind,
438447
actor_name=result.actor_name,
448+
# Superseded content also withholds the version: like content_checksum
449+
# above, a stale version must not invite consumers to reconcile to it.
450+
db_version=None if result.content_superseded else result.db_version,
439451
)
440452

441453

@@ -537,6 +549,8 @@ class IndexedFileLiveUpdatePlan:
537549
actor_user_profile_id: str | None = None
538550
actor_kind: str | None = None
539551
actor_name: str | None = None
552+
# Trusted-branch only, like the actor fields (#1589).
553+
db_version: int | None = None
540554
live_update_source: RuntimeNoteChangeSource | None = None
541555
operation: FileIndexOperation | None = None
542556

@@ -600,6 +614,7 @@ def plan_current_materialized_note_result(
600614
actor_kind=provenance.actor_kind,
601615
actor_name=provenance.actor_name,
602616
live_update_source=provenance.source,
617+
db_version=provenance.db_version,
603618
),
604619
object_checksum_source=plan.object_checksum_source,
605620
object_checksum=plan.object_checksum,
@@ -658,6 +673,7 @@ def plan_indexed_file_live_update_metadata(
658673
actor_kind=provenance.actor_kind,
659674
actor_name=provenance.actor_name,
660675
live_update_source=provenance.source,
676+
db_version=provenance.db_version,
661677
operation=file_index_operation_from_note_object_metadata(object_metadata),
662678
)
663679

src/basic_memory/runtime/note_object_metadata.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
VALID_NOTE_OBJECT_SOURCES: frozenset[RuntimeNoteChangeSource] = frozenset(
4444
{"api", "collaboration_relay", "mcp", "s3_webhook", "web_v2"}
4545
)
46+
# Named because the accepted-note write path special-cases relay writes: the
47+
# relay superseding its own prior write is never a real conflict (#1589).
48+
NOTE_SOURCE_COLLABORATION_RELAY: RuntimeNoteChangeSource = "collaboration_relay"
4649
_SAFE_ACTOR_NAME_CHARS = re.compile(r"[^A-Za-z0-9 ._()+/:-]+")
4750
_WHITESPACE = re.compile(r"\s+")
4851
_MAX_ACTOR_NAME_LENGTH = 120
@@ -227,6 +230,10 @@ class RuntimeNoteObjectProvenance:
227230
actor_kind: RuntimeNoteActorKind | None = None
228231
actor_name: RuntimeNoteActorName | None = None
229232
source: RuntimeNoteChangeSource | None = None
233+
# The accepted DB version mirrored onto the object (#1589): lets index-path
234+
# live updates carry the monotonic version so consumers can decide
235+
# echo-vs-out-of-band by arithmetic instead of checksum comparison.
236+
db_version: RuntimeNoteContentVersion | None = None
230237

231238
@classmethod
232239
def from_object_metadata(cls, metadata: RuntimeNoteObjectMetadataMap | None) -> Self:
@@ -237,6 +244,7 @@ def from_object_metadata(cls, metadata: RuntimeNoteObjectMetadataMap | None) ->
237244
actor_kind=actor_kind,
238245
actor_name=actor_name,
239246
source=source_from_object_metadata(metadata),
247+
db_version=db_version_from_object_metadata(metadata),
240248
)
241249

242250

tests/indexing/test_accepted_note_mutation_runner.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,7 @@ def _entity(
571571
)
572572

573573

574-
def _note_content(entity: Entity) -> NoteContent:
574+
def _note_content(entity: Entity, last_source: str | None = None) -> NoteContent:
575575
return NoteContent(
576576
entity_id=entity.id,
577577
project_id=entity.project_id,
@@ -583,7 +583,7 @@ def _note_content(entity: Entity) -> NoteContent:
583583
file_version=1,
584584
file_checksum="file-checksum",
585585
file_write_status="pending",
586-
last_source=None,
586+
last_source=last_source,
587587
)
588588

589589

@@ -927,6 +927,102 @@ async def test_run_accepted_note_update_rejects_stale_base_checksum() -> None:
927927
assert search_repository.calls == []
928928

929929

930+
@pytest.mark.asyncio
931+
async def test_run_accepted_note_update_accepts_relay_self_supersede_on_stale_base() -> None:
932+
# Lost-ack wedge regression (#1589, 2026-07-23 production incident): a relay
933+
# persist timed out client-side AFTER committing, so the accepted row is the
934+
# relay's own write while the relay's recorded base is one version behind.
935+
# The relay superseding its own prior write is never a real conflict - the
936+
# live Y.Doc is the merge of everything the relay ever persisted - so the
937+
# stale base must be accepted, not 409-wedged forever.
938+
session = _MutationSession()
939+
schema = _schema()
940+
project = _project()
941+
prepared = _prepared_replacement()
942+
entity = _entity(file_path="notes/accepted.md")
943+
note_content = _note_content(entity, last_source="collaboration_relay")
944+
project_repository = _ProjectRepository(project)
945+
entity_lookup_repository = _EntityLookupRepository(by_external_id=entity)
946+
note_content_lookup_repository = _NoteContentLookupRepository(note_content)
947+
preparer = _CreatePreparer(prepared)
948+
preparer_factory = _PreparerFactory(preparer)
949+
pending_entity_repository = _PendingEntityRepository(entity)
950+
note_content_accept_repository = _NoteContentAcceptRepository(note_content)
951+
search_repository = _SearchRepository()
952+
953+
change = await run_accepted_note_update(
954+
cast(AsyncSession, session),
955+
request=AcceptedNoteUpdateMutation(
956+
project_external_id="project-123",
957+
entity_external_id="note-123",
958+
data=schema,
959+
actor=AcceptedNoteMutationActor(user_profile_id=_ACTOR_ID),
960+
source="collaboration_relay",
961+
base_checksum="stale-checksum",
962+
),
963+
dependencies=_dependencies(
964+
project_repository=project_repository,
965+
entity_lookup_repository=entity_lookup_repository,
966+
note_content_lookup_repository=note_content_lookup_repository,
967+
preparer_factory=preparer_factory,
968+
pending_entity_repository=pending_entity_repository,
969+
note_content_accept_repository=note_content_accept_repository,
970+
search_repository=search_repository,
971+
),
972+
)
973+
974+
assert change.status_code == 200
975+
assert note_content_accept_repository.calls[0][1].db_version == 2
976+
assert note_content_accept_repository.calls[0][1].markdown_content == "# Replacement\n"
977+
978+
979+
@pytest.mark.asyncio
980+
async def test_run_accepted_note_update_relay_stale_base_still_rejects_foreign_writes() -> None:
981+
# The self-supersede rule is scoped to relay-over-relay only: when the
982+
# current accepted version came from a FOREIGN writer (MCP here), a stale
983+
# relay base is a genuine conflict and keeps the full guarded semantics.
984+
session = _MutationSession()
985+
schema = _schema()
986+
project = _project()
987+
prepared = _prepared_replacement()
988+
entity = _entity(file_path="notes/accepted.md")
989+
note_content = _note_content(entity, last_source="mcp")
990+
project_repository = _ProjectRepository(project)
991+
entity_lookup_repository = _EntityLookupRepository(by_external_id=entity)
992+
note_content_lookup_repository = _NoteContentLookupRepository(note_content)
993+
preparer = _CreatePreparer(prepared)
994+
preparer_factory = _PreparerFactory(preparer)
995+
pending_entity_repository = _PendingEntityRepository(entity)
996+
note_content_accept_repository = _NoteContentAcceptRepository(note_content)
997+
search_repository = _SearchRepository()
998+
999+
with pytest.raises(AcceptedNoteMutationRejected) as exc_info:
1000+
await run_accepted_note_update(
1001+
cast(AsyncSession, session),
1002+
request=AcceptedNoteUpdateMutation(
1003+
project_external_id="project-123",
1004+
entity_external_id="note-123",
1005+
data=schema,
1006+
actor=AcceptedNoteMutationActor(user_profile_id=_ACTOR_ID),
1007+
source="collaboration_relay",
1008+
base_checksum="stale-checksum",
1009+
),
1010+
dependencies=_dependencies(
1011+
project_repository=project_repository,
1012+
entity_lookup_repository=entity_lookup_repository,
1013+
note_content_lookup_repository=note_content_lookup_repository,
1014+
preparer_factory=preparer_factory,
1015+
pending_entity_repository=pending_entity_repository,
1016+
note_content_accept_repository=note_content_accept_repository,
1017+
search_repository=search_repository,
1018+
),
1019+
)
1020+
1021+
rejection = exc_info.value.rejection
1022+
assert rejection.kind is AcceptedNoteMutationRejectKind.conflict
1023+
assert note_content_accept_repository.calls == []
1024+
1025+
9301026
@pytest.mark.asyncio
9311027
async def test_run_accepted_note_update_rejects_base_checksum_when_entity_missing() -> None:
9321028
# A base_checksum with no addressed entity means the note was deleted after

tests/indexing/test_index_file_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ async def test_run_index_file_preserves_current_materialized_note_metadata() ->
281281
operation=FileIndexOperation.created,
282282
actor_user_profile_id="33333333-3333-3333-3333-333333333333",
283283
live_update_source="mcp",
284+
db_version=1,
284285
)
285286
assert metadata_source.paths == ["notes/a.md"]
286287
assert materialized_source.paths == ["notes/a.md"]

tests/indexing/test_models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,7 @@ def test_plan_current_materialized_note_result_preserves_trusted_live_update_met
775775
actor_kind=NOTE_OBJECT_ACTOR_KIND_MCP_CLIENT,
776776
actor_name="Claude Code",
777777
live_update_source="mcp",
778+
db_version=1,
778779
),
779780
object_checksum_source=RuntimeStorageObjectChecksumSource.note_file_checksum,
780781
object_checksum="checksum-1",
@@ -874,6 +875,7 @@ def test_plan_indexed_file_live_update_metadata_preserves_matching_metadata():
874875
actor_kind=NOTE_OBJECT_ACTOR_KIND_MCP_CLIENT,
875876
actor_name="Claude Code",
876877
live_update_source="mcp",
878+
db_version=2,
877879
operation=FileIndexOperation.updated,
878880
)
879881

tests/test_runtime.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,7 @@ def test_note_object_provenance_parses_trusted_actor_and_source(self):
11191119
NOTE_OBJECT_ACTOR_KIND_METADATA: NOTE_OBJECT_ACTOR_KIND_MCP_CLIENT,
11201120
NOTE_OBJECT_ACTOR_NAME_METADATA: " Pat\t\n<script>! ",
11211121
NOTE_OBJECT_SOURCE_METADATA: "mcp",
1122+
NOTE_OBJECT_DB_VERSION_METADATA: "7",
11221123
}
11231124
)
11241125

@@ -1127,6 +1128,7 @@ def test_note_object_provenance_parses_trusted_actor_and_source(self):
11271128
actor_kind=NOTE_OBJECT_ACTOR_KIND_MCP_CLIENT,
11281129
actor_name="Pat script",
11291130
source="mcp",
1131+
db_version=7,
11301132
)
11311133

11321134
untrusted_actor_name = RuntimeNoteObjectProvenance.from_object_metadata(

0 commit comments

Comments
 (0)