Compare commits

...
Author SHA1 Message Date
kristinhe d1ff4571bd fix(memhub): stop reading released provenance as a revoked source
A citation whose `evidence` row is gone made the whole memory unreadable:
`_MEMORY_READABLE` treated absence as "cannot prove the source is not
revoked", so `GET /api/v1/memories/{id}` answered 403 `memory_source_revoked`
(issue #157: 85 of 2,382 memories, on two sources nobody revoked). Evidence
rows are released by design once the SDK owns the content; the memory's text
lives in `governed_memories` and was never damaged. `get_memory` already
degraded an unresolvable citation to `EvidenceExcerpt(available=False)`, and
the 403 made that degradation unreachable.

The policy was also written four times and had drifted: `context_memory`
carried a comment asserting the opposite rule. It is now one function,
`memory_source_readable`, and the three other call sites use it.

Revocation is unchanged: it is recorded on the grant and never deletes
evidence, so a revoked source still matches.
2026-10-24 08:01:18 +08:00
7 changed files with 152 additions and 46 deletions

No files matched your search

+6
View File
@@ -38,6 +38,12 @@ Hy Memory and MemHub are versioned and released independently.
parked pre-invocation work, and drop only the obsolete
`hy_node_permissions` proof table. Evidence, governed memories, model
profiles, SDK stores, and source/host grants are preserved.
- A memory whose cited evidence row has been released no longer reads as a
revoked source. The read policy is now spelled once instead of in four
copies, so the memory library, the detail endpoint, call-event references
and context assembly agree that missing provenance is a fact about the
citation, not about the grant. Real revocations still hide memories; they
are recorded on the grant and never on the evidence row.
## Hy Memory 2.0.5 — 2026-10-22
@@ -10,6 +10,7 @@ from pydantic import Field, model_validator
from one_memory.call_event_store import CallEvent, CallReference
from one_memory.database_access import main_database
from one_memory.memory_evidence_freshness import register_memory_freshness
from one_memory.source_read_access import memory_source_readable
class SourceRecordRef(CallReference):
@@ -76,17 +77,13 @@ def _memory(db: sqlite3.Connection, reference: CallReference) -> SourceRecordRef
if row["status"] == "deleted" or row["deleted_at"] is not None:
return _hidden(reference)
# Same local revocation/ownership predicate as SourceReadAccess.memories.
# Its public methods open their own connections, so cannot be used here.
# Same local revocation/ownership predicate as SourceReadAccess.memories,
# taken from there rather than copied: its public methods open their own
# connections, so the predicate is the part that can be shared.
access = db.execute(
"SELECT EXISTS (SELECT 1 FROM governed_memory_evidence "
"WHERE memory_id=? AND active=1), NOT EXISTS ("
"SELECT 1 FROM governed_memory_evidence link "
"LEFT JOIN evidence e ON e.evidence_id=link.evidence_id "
"LEFT JOIN source_grants g ON g.user_id=e.user_id AND g.source_id=e.source_id "
"WHERE link.memory_id=? AND +link.active=1 "
"AND (e.evidence_id IS NULL OR e.user_id!='local' OR g.revoked_at IS NOT NULL))",
(reference.id, reference.id),
"WHERE memory_id=? AND active=1), " + memory_source_readable("?", "?"),
(reference.id, reference.id, "local"),
).fetchone()
if not all(access):
return _hidden(reference)
@@ -25,6 +25,7 @@ from one_memory.memory_evidence_freshness import register_memory_freshness
from one_memory.recall import _terms
from one_memory.retrieval.models import Channel, RetrievalPath, TimeRange
from one_memory.semantic_deadline import SemanticMemoryHit, SemanticOutcome
from one_memory.source_read_access import memory_source_readable
_BODY_LIMIT = 20000
_REFERENCE_LIMIT = 128
@@ -283,28 +284,18 @@ def _memory_readable(freshness: str) -> str:
return _READABLE + f" AND {freshness}"
# Match the local governed read policy before scoring or candidate LIMIT. A
# missing grant is not a revocation; a missing/cross-user active citation is.
_READABLE = """
# Match the local governed read policy before scoring or candidate LIMIT. That
# policy is `source_read_access`'s and is spelled once there so it cannot drift
# from the endpoint that answers 403; this module only adds its own status
# filter on top.
_READABLE = f"""
m.user_id = 'local' AND m.status = 'confirmed' AND m.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM governed_memory_evidence link
LEFT JOIN evidence e ON e.evidence_id = link.evidence_id
LEFT JOIN source_grants g ON g.user_id = e.user_id AND g.source_id = e.source_id
WHERE link.memory_id = m.memory_id AND +link.active = 1
AND (e.evidence_id IS NULL OR e.user_id != m.user_id OR g.revoked_at IS NOT NULL)
)
AND {memory_source_readable("m.memory_id", "m.user_id")}
"""
_SDK_MEMBER_READABLE = """
_SDK_MEMBER_READABLE = f"""
m.user_id = 'local' AND m.status != 'deleted' AND m.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM governed_memory_evidence link
LEFT JOIN evidence e ON e.evidence_id = link.evidence_id
LEFT JOIN source_grants g ON g.user_id = e.user_id AND g.source_id = e.source_id
WHERE link.memory_id = m.memory_id AND +link.active = 1
AND (e.evidence_id IS NULL OR e.user_id != m.user_id OR g.revoked_at IS NOT NULL)
)
AND {memory_source_readable("m.memory_id", "m.user_id")}
"""
@@ -14,24 +14,44 @@ from one_memory.memory_governance import GovernedMemory
from one_memory.memory_visibility import mode as visibility_mode
from one_memory.memory_visibility import readable_ids
# A memory is readable while every active citation is: still current, owned by
# the reader, and not on a revoked source. `+link.active` keeps SQLite on the
# (memory_id, evidence_id) primary key. Left on its own, SQLite picks the
# (active, evidence_id, memory_id) index and walks every active link in the
# library once per memory, which on a 1,226-memory library took 5.4 s for 500
# memories and hit the 5 s interrupt, so the memory library and the memory map
# both answered 503 (D78 missed this path).
_MEMORY_READABLE = """
is_memory_evidence_current(?,?,?) AND NOT EXISTS (
def memory_source_readable(memory_id: str, user_id: str) -> str:
"""SQL: no active citation of this memory sits behind a revoked source.
Takes expressions rather than values because the callers differ: the
checks here bind a parameter, `context_memory` reads an already-open row.
Absence is not revocation, and used to be read as one. A citation whose
`evidence` row is gone proves nothing about the grant — the row was
released as import staging, not by the user — so it neither hides the
memory nor excuses it. `_revoked_evidence` below has always answered it
that way; only this predicate disagreed, and it answered 403
`memory_source_revoked` for 85 of 2,382 memories on two sources nobody
revoked (issue #157). Revocation itself is unaffected: it marks the grant
and never deletes evidence, so a revoked source still has a row here to
match against.
`+link.active` keeps SQLite on the (memory_id, evidence_id) primary key.
Left on its own, SQLite picks the (active, evidence_id, memory_id) index
and walks every active link in the library once per memory, which on a
1,226-memory library took 5.4 s for 500 memories and hit the 5 s
interrupt, so the memory library and the memory map both answered 503
(D78 missed this path).
"""
return f"""
NOT EXISTS (
SELECT 1 FROM governed_memory_evidence link
LEFT JOIN evidence e ON e.evidence_id=link.evidence_id
LEFT JOIN source_grants g ON g.user_id=e.user_id AND g.source_id=e.source_id
WHERE link.memory_id=? AND +link.active=1
AND (e.evidence_id IS NULL OR e.user_id!=? OR g.revoked_at IS NOT NULL)
WHERE link.memory_id={memory_id} AND +link.active=1
AND (e.user_id!={user_id} OR g.revoked_at IS NOT NULL)
)
"""
_MEMORY_READABLE = "is_memory_evidence_current(?,?,?) AND " + memory_source_readable("?", "?")
class SourceReadAccess:
def __init__(self, database_path: Path) -> None:
self.database_path = database_path
+24 -4
View File
@@ -131,7 +131,10 @@ def test_scope_user_status_and_active_source_isolation(database):
revoke(db, "workbuddy")
revoke(db, "cursor", user="other")
assert ids(read(database, scopes=frozenset({"workspace:one"}))) == ["workspace"]
assert ids(read(database, scopes=frozenset({"all-apps"}))) == [
# `dangling` cites an evidence row that is gone. That is released
# provenance, not a revocation, so the memory reads like any other.
assert sorted(ids(read(database, scopes=frozenset({"all-apps"})))) == [
"dangling",
"global",
"inactive",
"no-evidence",
@@ -375,7 +378,7 @@ def test_lock_wait_is_bounded_by_shared_deadline(database):
writer.rollback()
@pytest.mark.parametrize("change", ["revoke", "revision", "scope", "delete", "bad-link", "user"])
@pytest.mark.parametrize("change", ["revoke", "revision", "scope", "delete", "user"])
def test_final_revalidation_drops_changed_items_without_new_text(database, change):
with closing(sqlite3.connect(database)) as db, db:
put(db, "change", links=("e2",))
@@ -385,8 +388,6 @@ def test_final_revalidation_drops_changed_items_without_new_text(database, chang
with closing(sqlite3.connect(database)) as db, db:
if change == "revoke":
revoke(db, "workbuddy")
elif change == "bad-link":
db.execute("DELETE FROM evidence WHERE evidence_id='e2'")
else:
assignment = {
"revision": "revision=8,content='new text never return'",
@@ -402,6 +403,25 @@ def test_final_revalidation_drops_changed_items_without_new_text(database, chang
assert validated[0] is original[1]
def test_released_provenance_is_not_a_change_to_the_memory(database):
"""Revalidation is about the memory, and its evidence row is not it.
A citation can be released as import staging; that says nothing about the
text, the scopes or the grant, so the item keeps its place in the batch.
"""
with closing(sqlite3.connect(database)) as db, db:
put(db, "released", links=("e2",))
original = read(database, scopes=frozenset({"all-apps"})).items
assert [item.memory_id for item in original] == ["released"]
with closing(sqlite3.connect(database)) as db, db:
db.execute("DELETE FROM evidence WHERE evidence_id='e2'")
validated = revalidate_memory_context(
database, original, scopes=frozenset({"all-apps"}), deadline=time.monotonic() + 2
)
assert [item.memory_id for item in validated] == ["released"]
assert validated[0] is original[0]
def test_final_revalidation_failure_propagates_for_aggregator_not_false_empty(database):
with closing(sqlite3.connect(database)) as db, db:
put(db, "memory")
@@ -21,14 +21,13 @@ def _predicates():
home = re.search(r'readable = """(.*?)"""', _source(home_summary), re.S).group(1)
library = _source(memory_library)
library_clauses = re.findall(r'clauses\.append\("""(NOT EXISTS.*?)"""\)', library, re.S)
access = re.search(
r"_MEMORY_READABLE = \"\"\"(.*?)\"\"\"", _source(source_read_access), re.S
).group(1)
return {
"home": (home, ("local",)),
"library": ("m.user_id = ? AND " + " AND ".join(library_clauses), ("local",)),
"context": (context_memory._READABLE, ()),
"source_read_access": (access, ()),
# Built rather than lifted out of the module's source: the policy is a
# function now, so what has to be planned is what it returns.
"source_read_access": (source_read_access._MEMORY_READABLE, ()),
}
@@ -134,3 +134,76 @@ def test_the_read_does_not_scale_its_query_count_with_the_page(library):
# One batched read per call, carrying the whole page — not one per memory.
assert small == [3]
assert large == [12]
def test_a_citation_without_its_evidence_row_stays_readable(tmp_path) -> None:
"""Missing provenance is not a revoked source.
Regression: `_MEMORY_READABLE` treated an active citation whose `evidence`
row was gone as unreadable and answered 403 `memory_source_revoked`. The
memory's own content is in `governed_memories`, and the detail response
already degrades a missing citation to `available=False`, so the upstream
403 made that degradation unreachable and blamed a source nobody revoked.
"""
from contextlib import closing
from one_memory.memory_governance import HyCreate, MemoryOrigin
database = tmp_path / "one-memory.sqlite3"
with closing(sqlite3.connect(database)) as setup:
setup.executescript(GRANT_SCHEMA)
evidence = EvidenceStore(database)
governance = MemoryGovernanceStore(database)
record = evidence.put(
EvidenceInput(
source_id="workbuddy",
conversation_id="fixture",
message_id="one",
role="user",
written_by=WrittenBy.PERSON,
verbatim="真实对话内容。",
origin_meta={EvidenceOrigin.FILE: OriginMetadata()},
)
).record
memory = governance.write_from_hy(
HyCreate(
draft=MemoryDraft(
user_id="local",
kind=MemoryKind.CHAT,
content="真实对话内容。",
scopes=("all-apps",),
evidence_ids=(record.evidence_id,),
origin=MemoryOrigin.EXTRACTED,
),
request_id="hy-0",
memory_id="hy-memory-0",
),
user_id="local",
derivation=governance.hy_derivation((record.evidence_id,)),
)
with closing(sqlite3.connect(database)) as db, db:
db.execute(
"INSERT INTO source_grants(user_id,source_id,root_path,requested_path,"
"root_identity,candidate_kind,granted_at,revoked_at,revision) "
"VALUES('local','workbuddy','/synthetic','/synthetic','fixture',"
"'default','2026-10-01T00:00:00+00:00',NULL,1)"
)
access = SourceReadAccess(database)
assert access.memories([memory]) == [memory], "granted and cited: readable"
# A real revocation still hides it. Checked while the evidence row is
# still here, because attribution to a source lives only on that row.
with closing(sqlite3.connect(database)) as db, db:
db.execute(
"UPDATE source_grants SET revoked_at='2026-10-02T00:00:00+00:00' "
"WHERE user_id='local' AND source_id='workbuddy'"
)
assert access.memories([memory]) == [], "revoked source hides the memory"
with closing(sqlite3.connect(database)) as db, db:
db.execute("UPDATE source_grants SET revoked_at=NULL WHERE user_id='local'")
# The evidence row disappears — released as import staging, not revoked.
with closing(sqlite3.connect(database)) as db, db:
db.execute("DELETE FROM evidence WHERE evidence_id=?", (record.evidence_id,))
assert access.memories([memory]) == [memory], "missing provenance still reads"