Describe the bug
RFC 0014 and the Memory protocol name the backend as the final trust boundary: commit() must validate and atomically persist one complete Revision in the same transaction, and search must filter every candidate through the authoritative head manifest. The current implementation does not honor that contract.
This is not a default HTTP/MCP remember, revise, retire, or flush incident. Those paths go through MemoryService, which currently fills predecessor, hash, and projections itself. The gap is the public SPI: MemoryCommit, MemoryWritePlan, and MemoryBackend allow a writer to bypass the service and call backend.commit() directly. Tests and a later writer can already take that seam.
What the contract says, and what the code does
- The protocol requires
MemoryUnitOfWork.commit() to validate and atomically commit one complete Revision. _validate_commit() only checks family, the whole-document content_hash, revision +1, base identity, and equality of the active/projection version-id sets.
- The RFC sequence diagram requires the backend to validate identity, predecessor, manifest, hash, and vectors. The implementation does not recompute the entry body hash, validate the direct predecessor, or require projections to match the authoritative entry.
- The RFC schema draws a self-referencing foreign key on
previous_version_id. pc_memory_entry_versions.previous_version_id is a plain string column.
- The RFC requires search to revalidate active membership, exact version, and canonical bytes against the authoritative manifest. The implementation only confirms that the target is still the current head, so the index can act as a second content authority.
- The RFC describes projections and indexes as rebuildable, non-authoritative copies. Hit text can come from an unrevalidated index/body and no longer line up with the manifest hash.
What has already been reproduced
- A revision-1 entry with
previous_version_id="does-not-exist" commits and reads back.
- A body replaced with
tampered searchable body. while keeping the hash of User prefers black tea. also commits.
- In that corrupted state,
search() returns the tampered body, while entries() raises InvalidMemoryCitationError for the same revision.
entries() also omits the version.entry_content_hash comparison that _validate_anchor() already performs.
Closing only the write path or only the read path leaves the loop open. Both sides should be fixed in one PR.
What this gap can cause later
Official product paths almost never construct a bad envelope today, so normal use usually does not show the damage. If the seam is taken, or if the service or a second writer later emits a bad commit, the likely consequences are:
- Illegal history becomes durable authority. A hash is then a label written with the envelope, not a commitment.
- A fake predecessor has almost no current read consumer, but later lineage, offline validation, or citation code that walks
previous_version_id will not be able to rebuild history reliably.
- After a body / declared / manifest hash mismatch, remember, revise, forget, and organize fail with
hash-mismatch. The Memory can still be recalled but can no longer be governed.
- If a projection or
searchable_text diverges from the canonical row, search and automatic POST /v1/context/prepare injection can hand a different body to the model.
- Context Pack and Handoff can consume text the authoritative manifest never committed.
- A new adapter, migration script, or homemade
MemoryWritePlan will copy the current thin checks as the contract, and the gap will spread.
- Fixing only one side still lets dirty data in, or still lets search return it after it is in.
Steps to reproduce
- Use a PowerContext checkout with its dependencies installed.
- Save the following as
repro_memory_integrity.py.
- Run:
python repro_memory_integrity.py
#!/usr/bin/env python3
"""Reproduce Memory commit/search integrity gap."""
from __future__ import annotations
import asyncio
import json
from tempfile import TemporaryDirectory
from powercontext.builtin.artifacts.memory import (
Memory,
MemoryCommit,
MemoryContent,
MemoryEntryVersion,
MemoryManifest,
MemoryManifestEntry,
MemoryProjection,
MemoryService,
)
from powercontext.builtin.artifacts.memory.canonical import entry_content_hash, memory_content_hash
from powercontext.builtin.artifacts.memory.errors import InvalidMemoryCitationError
from powercontext.builtin.artifacts.search import analyze_text
from powercontext.builtin.persistence.memory import RelationalMemoryBackend
from powercontext.builtin.persistence.sqlite import SQLiteConfig
from powercontext.builtin.runtime import BuiltinConfig, open_builtin_contexts
ORIGINAL = "User prefers black tea."
TAMPERED = "tampered searchable body."
def _version(*, text: str, content_hash: str, previous_version_id: str | None) -> MemoryEntryVersion:
return MemoryEntryVersion(
memory_artifact_id="memory",
entry_id="pref-tea",
entry_version_id="pref-tea-v1",
version=1,
previous_version_id=previous_version_id,
kind="fact",
text=text,
entry_content_hash=content_hash,
created_in_revision=1,
)
def _commit(version: MemoryEntryVersion) -> MemoryCommit:
content = MemoryContent(
manifest=MemoryManifest(
entries=(
MemoryManifestEntry(
entry_id=version.entry_id,
entry_version_id=version.entry_version_id,
entry_content_hash=version.entry_content_hash,
state="active",
),
)
)
)
memory = Memory(artifact_id="memory", revision=1, content=content)
return MemoryCommit(
base=None,
memory=memory,
content_hash=memory_content_hash(content),
entry_versions=(version,),
projections=(
MemoryProjection(
entry_version=version,
searchable_text=analyze_text(version.text),
),
),
)
def _backend(contexts: object, scope_id: str) -> RelationalMemoryBackend:
return RelationalMemoryBackend(
database=contexts.database,
scope_id=scope_id,
artifacts=contexts.repositories.artifacts,
index=contexts.index,
)
async def main() -> None:
original_hash = entry_content_hash(
kind="fact",
text=ORIGINAL,
source_refs=(),
artifact_refs=(),
)
with TemporaryDirectory() as directory:
config = BuiltinConfig(
database=SQLiteConfig(url=f"sqlite+aiosqlite:///{directory}/repro.db")
)
async with open_builtin_contexts(config) as contexts:
fake_backend = _backend(contexts, "fake-predecessor")
fake_version = _version(
text=ORIGINAL,
content_hash=original_hash,
previous_version_id="does-not-exist",
)
async with fake_backend.begin() as unit_of_work:
committed = await unit_of_work.commit(_commit(fake_version))
stored = (await fake_backend.entries(committed.as_ref()))[0]
mismatch_backend = _backend(contexts, "hash-mismatch")
mismatch_version = _version(
text=TAMPERED,
content_hash=original_hash,
previous_version_id=None,
)
async with mismatch_backend.begin() as unit_of_work:
mismatch_memory = await unit_of_work.commit(_commit(mismatch_version))
service = MemoryService(backend=mismatch_backend)
search = await service.search(
"tampered searchable",
memories=(mismatch_memory,),
mode="fts",
)
entries_error = None
try:
await service.entries(mismatch_memory)
except InvalidMemoryCitationError as error:
entries_error = type(error).__name__
observed = {
"entries_error": entries_error,
"fake_predecessor": stored.previous_version_id,
"search_hits": [hit.text for hit in search.hits],
}
print(json.dumps(observed, sort_keys=True))
expected = {
"entries_error": "InvalidMemoryCitationError",
"fake_predecessor": "does-not-exist",
"search_hits": [TAMPERED],
}
if observed != expected:
raise SystemExit(1)
print("0")
if __name__ == "__main__":
asyncio.run(main())
- Observe that the process prints the JSON below and exits 0. That means it observed the known buggy state.
Do not use HTTP remember/flush as the minimal reproduction. That path currently fills the envelope correctly and does not prove the contract gap.
Expected behavior
The backend is the final trust boundary. commit() must reject an illegal Revision in the same transaction, including:
- Nonexistent, cross-entry, cross-Memory, or skipped predecessors, and a first version with a non-null predecessor.
- Body / declared-hash / manifest-hash mismatches.
- Duplicate, missing, or extra entry versions, and projections that do not match the active manifest entry or canonical
searchable_text.
- A failed validation must roll back the whole transaction.
Search must treat the index as a rebuildable projection, not a second authority:
- Before returning, each hit must be revalidated in the same read transaction against the exact revision, manifest, and entry: active membership, identity, version, and canonical bytes.
- For a given corrupted state,
search(), entries(), and expand() must agree: they either succeed together and return the same authoritative body, or fail together with an integrity error.
- They must never return the suspect body.
The RFC-legal degradation “authoritative rows present, vector absent” must remain accepted. Existing stale-head concurrency semantics should stay unchanged.
Actual behavior
The reproducer consistently prints:
{"entries_error": "InvalidMemoryCitationError", "fake_predecessor": "does-not-exist", "search_hits": ["tampered searchable body."]}
0
That matches the reproduced items above:
- The fake predecessor was stored as an authoritative row.
- The body/hash mismatch was accepted.
search() returned the tampered body while entries() rejected the same revision.
The final 0 and process exit code 0 mean the reproducer observed the known buggy state.
Environment
- PowerContext version: current mainline
- Commit:
7bddf6abde482b6081b9984d1ca0dbae183e3946
- Storage backend: local SQLite
- External services: none
Related but not a duplicate: #1249 fixed a concurrent stale-head FTS miss and did not add authoritative candidate validation. PR #1293 added seekDB and did not change these validators.
Are you willing to submit a PR to fix this bug?
Describe the bug
RFC 0014 and the Memory protocol name the backend as the final trust boundary:
commit()must validate and atomically persist one complete Revision in the same transaction, and search must filter every candidate through the authoritative head manifest. The current implementation does not honor that contract.This is not a default HTTP/MCP remember, revise, retire, or flush incident. Those paths go through
MemoryService, which currently fills predecessor, hash, and projections itself. The gap is the public SPI:MemoryCommit,MemoryWritePlan, andMemoryBackendallow a writer to bypass the service and callbackend.commit()directly. Tests and a later writer can already take that seam.What the contract says, and what the code does
MemoryUnitOfWork.commit()to validate and atomically commit one complete Revision._validate_commit()only checks family, the whole-documentcontent_hash, revision +1, base identity, and equality of the active/projection version-id sets.previous_version_id.pc_memory_entry_versions.previous_version_idis a plain string column.What has already been reproduced
previous_version_id="does-not-exist"commits and reads back.tampered searchable body.while keeping the hash ofUser prefers black tea.also commits.search()returns the tampered body, whileentries()raisesInvalidMemoryCitationErrorfor the same revision.entries()also omits theversion.entry_content_hashcomparison that_validate_anchor()already performs.Closing only the write path or only the read path leaves the loop open. Both sides should be fixed in one PR.
What this gap can cause later
Official product paths almost never construct a bad envelope today, so normal use usually does not show the damage. If the seam is taken, or if the service or a second writer later emits a bad commit, the likely consequences are:
previous_version_idwill not be able to rebuild history reliably.hash-mismatch. The Memory can still be recalled but can no longer be governed.searchable_textdiverges from the canonical row, search and automaticPOST /v1/context/prepareinjection can hand a different body to the model.MemoryWritePlanwill copy the current thin checks as the contract, and the gap will spread.Steps to reproduce
repro_memory_integrity.py.Do not use HTTP remember/flush as the minimal reproduction. That path currently fills the envelope correctly and does not prove the contract gap.
Expected behavior
The backend is the final trust boundary.
commit()must reject an illegal Revision in the same transaction, including:searchable_text.Search must treat the index as a rebuildable projection, not a second authority:
search(),entries(), andexpand()must agree: they either succeed together and return the same authoritative body, or fail together with an integrity error.The RFC-legal degradation “authoritative rows present, vector absent” must remain accepted. Existing stale-head concurrency semantics should stay unchanged.
Actual behavior
The reproducer consistently prints:
{"entries_error": "InvalidMemoryCitationError", "fake_predecessor": "does-not-exist", "search_hits": ["tampered searchable body."]} 0That matches the reproduced items above:
search()returned the tampered body whileentries()rejected the same revision.The final
0and process exit code 0 mean the reproducer observed the known buggy state.Environment
7bddf6abde482b6081b9984d1ca0dbae183e3946Related but not a duplicate: #1249 fixed a concurrent stale-head FTS miss and did not add authoritative candidate validation. PR #1293 added seekDB and did not change these validators.
Are you willing to submit a PR to fix this bug?