A holder is now somebody who can prove it. Schema v11 stores a sha256 of a per-holder token beside the directory row, so a copied database cannot replay it and the plaintext is shown exactly once. The operator token mints and revokes those tokens; a holder token reads that holder's own memories and nothing else; naming somebody else without the operator token is refused with both ids in the message. The visibility rule was leaking before this: browse had none at all, and the search rules short-circuited to the whole library whenever no holder was named. The rule is now written out in five copies with one parameter carried four times, and a test compares the copies so they cannot drift — semgrep rejects an interpolated constant, so the copies are not a choice. Eyes carry three states on purpose. NULL means the whole library, which is what a process that owns the store is entitled to; a holder id means that holder; the empty string means nobody, which is what a tokenless HTTP caller is. Making NULL mean nobody instead would have forced every internal caller — CLI, proofs, the compiler, the bench harnesses — to name eyes, contradicting the rule that a local process reads as its own trusted principal. The capability card answers "can this store answer my kind of question" before anything is sent. It is derived from what the store holds, so it cannot promise a modality the store lacks, and it carries no memory text, so it is safe to read before trusting the store. HTTP, CLI and one MCP tool all serve it without a token.
296 lines
13 KiB
Python
296 lines
13 KiB
Python
"""The transactive test: I do not know where the keys are, I know my wife knows, I find my wife."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import ValidationError
|
|
from typer.testing import CliRunner
|
|
|
|
import ely_eye.api as api_module
|
|
import ely_eye.cli as cli_module
|
|
from ely_eye.cli import app
|
|
from ely_eye.config import Settings
|
|
from ely_eye.db import Database
|
|
from ely_eye.holder_store import HolderStore
|
|
from ely_eye.ingestion import IngestionService
|
|
from ely_eye.memory import MemoryService
|
|
from ely_eye.migrations import SCHEMA_VERSION
|
|
from ely_eye.schemas import MemoryOrigin, Visibility, utc_now
|
|
from ely_eye.transactive import LIBRARY, NO_HOLDER_KNOWS, TransactiveService
|
|
from ely_eye.transactive_schemas import Holder
|
|
|
|
HOME_KEYS = "我们的钥匙放在玄关抽屉里"
|
|
WORK_KEYS = "备用钥匙在办公室第二个抽屉里"
|
|
QUERY = "钥匙"
|
|
|
|
|
|
class Store:
|
|
"""One SQLite file, the writer and the directory that read it."""
|
|
|
|
def __init__(self, settings: Settings) -> None:
|
|
self.settings = settings
|
|
self.memory = MemoryService(settings=settings)
|
|
self.db = self.memory.db
|
|
self.transactive = TransactiveService(settings=settings, database=self.db)
|
|
|
|
def wife_remembers(self, text: str, visibility: Visibility | None = None) -> str:
|
|
atom = self.memory.remember(
|
|
text, holder_id="wife", origin=MemoryOrigin.self_, scenario="home", visibility=visibility
|
|
)
|
|
return atom.atom_id
|
|
|
|
def husband_remembers(self, text: str) -> str:
|
|
atom = self.memory.remember(text, holder_id="husband", origin=MemoryOrigin.self_, scenario="home")
|
|
return atom.atom_id
|
|
|
|
def register(self, holder_id: str, **fields: Any) -> Holder:
|
|
moment = utc_now()
|
|
return self.transactive.holders.upsert(
|
|
Holder(holder_id=holder_id, created_at=moment, updated_at=moment, **fields)
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path: Path) -> Store:
|
|
return Store(Settings(home=tmp_path / "home", default_holder="husband"))
|
|
|
|
|
|
def test_the_directory_names_the_holder_and_says_why(store: Store) -> None:
|
|
store.wife_remembers(HOME_KEYS)
|
|
report = store.transactive.who_knows(QUERY, as_holder="husband")
|
|
assert report.status == "found"
|
|
assert report.hits[0].holder_id == "wife"
|
|
assert report.hits[0].confidence > 0.0
|
|
assert any("matching memories" in line for line in report.hits[0].basis)
|
|
|
|
|
|
def test_the_directory_counts_a_memory_the_asker_cannot_read(store: Store) -> None:
|
|
"""The whole point: knowing that she knows does not require reading her memory."""
|
|
store.wife_remembers(HOME_KEYS)
|
|
assert store.transactive.who_knows(QUERY, as_holder="husband").hits[0].matched == 1
|
|
assert store.transactive._search(QUERY, as_holder="husband", holder="wife") == []
|
|
|
|
|
|
def test_a_private_memory_is_referred_and_not_leaked(store: Store) -> None:
|
|
store.register("wife", name="小美", contact={"channel": "signal", "address": "+86-555"})
|
|
store.wife_remembers(HOME_KEYS)
|
|
answer = store.transactive.ask("钥匙在哪里", as_holder="husband")
|
|
assert answer.status == "referred"
|
|
assert answer.answered_by == "wife"
|
|
assert answer.hops == 1
|
|
assert answer.asked == ["husband", "wife"]
|
|
assert answer.answer is None
|
|
assert answer.atom_ids == []
|
|
assert any("may not read" in line for line in answer.basis)
|
|
assert any("signal" in line for line in answer.basis)
|
|
|
|
|
|
def test_the_askers_own_memory_answers_without_a_referral(store: Store) -> None:
|
|
store.wife_remembers(HOME_KEYS)
|
|
store.husband_remembers("我的工牌挂在显示器边上")
|
|
answer = store.transactive.ask("工牌", as_holder="husband")
|
|
assert answer.status == "answered"
|
|
assert answer.answered_by == "husband"
|
|
assert answer.hops == 0
|
|
assert answer.asked == ["husband"]
|
|
assert answer.answer is None
|
|
|
|
|
|
def test_text_shown_only_when_the_asker_may_already_read_it(store: Store) -> None:
|
|
store.husband_remembers("我的工牌挂在显示器边上")
|
|
with_text = store.transactive.ask("工牌", as_holder="husband", include_text=True)
|
|
assert with_text.answer == "我的工牌挂在显示器边上"
|
|
|
|
store.register("wife")
|
|
store.wife_remembers(HOME_KEYS)
|
|
referred = store.transactive.ask("钥匙在哪里", as_holder="husband", include_text=True)
|
|
assert referred.status == "referred"
|
|
assert referred.answer is None
|
|
|
|
|
|
def test_a_shared_memory_is_readable_without_a_referral(store: Store) -> None:
|
|
"""A memory the asker may already read answers at hop 0: no referral, the text is shown."""
|
|
store.register("wife")
|
|
store.wife_remembers(HOME_KEYS, visibility=Visibility(scope="visible_to", holders=["husband"]))
|
|
answer = store.transactive.ask("钥匙在哪里", as_holder="husband", include_text=True)
|
|
assert answer.status == "answered"
|
|
assert answer.answered_by == "wife"
|
|
assert answer.hops == 0
|
|
assert answer.answer == HOME_KEYS
|
|
|
|
|
|
def test_an_unowned_artifact_answers_as_the_library(store: Store) -> None:
|
|
notebook = store.settings.home / "notebook.txt"
|
|
notebook.write_text("the garage door code is written on the fuse box", encoding="utf-8")
|
|
IngestionService(store.settings).ingest_path(notebook)
|
|
answer = store.transactive.ask("garage door code", as_holder="wife", include_text=True)
|
|
assert answer.status == "answered"
|
|
assert answer.answered_by == LIBRARY
|
|
assert "fuse box" in (answer.answer or "")
|
|
|
|
|
|
def test_nobody_knows_is_a_result_and_not_a_fabrication(store: Store) -> None:
|
|
store.wife_remembers(HOME_KEYS)
|
|
report = store.transactive.who_knows("潜水镜", as_holder="husband")
|
|
assert report.status == NO_HOLDER_KNOWS
|
|
assert report.hits == []
|
|
|
|
answer = store.transactive.ask("潜水镜在哪里", as_holder="husband")
|
|
assert answer.status == NO_HOLDER_KNOWS
|
|
assert answer.answered_by is None
|
|
assert answer.answer is None
|
|
assert answer.basis == ["no holder has a memory that answers this"]
|
|
|
|
|
|
def test_the_referral_walks_once_and_never_asks_the_same_holder_twice(store: Store) -> None:
|
|
store.register("wife")
|
|
store.register("daughter")
|
|
store.wife_remembers(HOME_KEYS)
|
|
store.memory.remember(WORK_KEYS, holder_id="daughter", origin=MemoryOrigin.self_, scenario="work")
|
|
answer = store.transactive.ask("钥匙在哪里", as_holder="husband", max_hops=1)
|
|
assert answer.status in ("referred", "answered")
|
|
assert len(answer.asked) == 2
|
|
assert len(set(answer.asked)) == len(answer.asked)
|
|
assert answer.asked[0] == "husband"
|
|
|
|
|
|
def test_the_hop_limit_is_enforced(store: Store) -> None:
|
|
for bad in (0, 11):
|
|
with pytest.raises(ValueError, match="max_hops between 1 and 10"):
|
|
store.transactive.ask(QUERY, as_holder="husband", max_hops=bad)
|
|
|
|
|
|
def test_declared_topics_raise_the_confidence_and_say_so(store: Store) -> None:
|
|
store.wife_remembers(HOME_KEYS)
|
|
before = store.transactive.who_knows(QUERY, as_holder="husband").hits[0]
|
|
store.register("wife", knows_about=["家里的钥匙"])
|
|
after = store.transactive.who_knows(QUERY, as_holder="husband").hits[0]
|
|
assert after.confidence > before.confidence
|
|
assert any("declared topics" in line for line in after.basis)
|
|
|
|
|
|
def test_the_registry_refuses_to_delete_a_holder_who_still_holds(store: Store) -> None:
|
|
store.register("wife")
|
|
atom_id = store.wife_remembers(HOME_KEYS)
|
|
with pytest.raises(ValueError, match="still holds 1 memories"):
|
|
store.transactive.holders.delete("wife")
|
|
store.memory.forget(atom_id=atom_id)
|
|
store.transactive.holders.delete("wife")
|
|
assert store.transactive.holders.list_holders() == []
|
|
|
|
|
|
def test_an_unknown_holder_kind_is_refused_at_the_door() -> None:
|
|
with pytest.raises(ValidationError, match="'person', 'agent' or 'service'"):
|
|
Holder.model_validate({"holder_id": "ghost", "kind": "ghost", "created_at": utc_now(), "updated_at": utc_now()})
|
|
|
|
|
|
def test_a_blank_holder_id_is_refused() -> None:
|
|
with pytest.raises(ValidationError, match="directory entry has to name somebody"):
|
|
Holder.model_validate({"holder_id": " ", "created_at": utc_now(), "updated_at": utc_now()})
|
|
|
|
|
|
def test_a_store_without_holders_gains_the_table_on_reopen(tmp_path: Path) -> None:
|
|
settings = Settings(home=tmp_path / "home", default_holder="husband")
|
|
database = Database(settings)
|
|
with database.connect() as connection:
|
|
connection.execute("DROP TABLE holders")
|
|
connection.execute("PRAGMA user_version = 4")
|
|
reopened = Database(settings)
|
|
with reopened.connect() as connection:
|
|
assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
|
|
assert connection.execute("SELECT COUNT(*) AS n FROM holders").fetchone()["n"] == 0
|
|
|
|
|
|
def test_the_http_surface_serves_the_directory(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, write_token: str
|
|
) -> None:
|
|
settings = Settings(home=tmp_path / "home", default_holder="husband", auth_token=write_token)
|
|
monkeypatch.setattr(api_module, "get_settings", lambda: settings)
|
|
client = TestClient(api_module.create_app(), headers={"Authorization": f"Bearer {write_token}"})
|
|
|
|
store = Store(settings)
|
|
store.wife_remembers(HOME_KEYS)
|
|
|
|
created = client.post(
|
|
"/api/holders/wife",
|
|
json={"name": "小美", "knows_about": ["家里的钥匙"], "contact": {"channel": "signal"}},
|
|
)
|
|
assert created.status_code == 200
|
|
assert created.json()["holder_id"] == "wife"
|
|
assert client.get("/api/holders").json()[0]["name"] == "小美"
|
|
assert client.get("/api/holders/nobody").status_code == 404
|
|
|
|
knows = client.get("/api/holders/wife/knows", params={"query": QUERY}).json()
|
|
assert knows["status"] == "found" and knows["hits"][0]["holder_id"] == "wife"
|
|
|
|
asked = client.post("/api/ask", json={"question": "钥匙在哪里", "as_holder": "husband"}).json()
|
|
assert asked["status"] == "referred" and asked["answered_by"] == "wife"
|
|
|
|
who = client.post("/api/who_knows", json={"query": "钥匙", "as_holder": "husband"}).json()
|
|
assert who["hits"][0]["basis"]
|
|
|
|
blocked = client.delete("/api/holders/wife")
|
|
assert blocked.status_code == 409 and "still holds" in blocked.json()["detail"]
|
|
|
|
|
|
def test_the_cli_asks_and_reports_the_referral(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
settings = Settings(home=tmp_path / "home", default_holder="husband")
|
|
monkeypatch.setattr(cli_module, "get_settings", lambda: settings)
|
|
runner = CliRunner()
|
|
Store(settings).wife_remembers(HOME_KEYS)
|
|
|
|
assert runner.invoke(app, ["holder-set", "wife", "--name", "小美", "--contact", "channel=signal"]).exit_code == 0
|
|
who = runner.invoke(app, ["who-knows", QUERY])
|
|
assert who.exit_code == 0 and '"holder_id": "wife"' in who.stdout
|
|
asked = runner.invoke(app, ["ask", "钥匙在哪里"])
|
|
assert asked.exit_code == 0 and '"status": "referred"' in asked.stdout
|
|
assert runner.invoke(app, ["holders"]).stdout.count("wife") >= 1
|
|
assert runner.invoke(app, ["holder-set", "wife", "--contact", "not-a-pair"]).exit_code != 0
|
|
|
|
|
|
def test_the_cli_mints_a_token_that_proves_who_is_asking(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
settings = Settings(home=tmp_path / "home", default_holder="husband")
|
|
monkeypatch.setattr(cli_module, "get_settings", lambda: settings)
|
|
runner = CliRunner()
|
|
Store(settings).wife_remembers(HOME_KEYS)
|
|
|
|
issued = json.loads(runner.invoke(app, ["holder-issue", "wife"]).stdout)
|
|
assert issued["holder_id"] == "wife" and issued["token"] and issued["issued_at"]
|
|
assert HolderStore(Database(settings)).verify_token(issued["token"]) == "wife"
|
|
assert issued["token"] not in runner.invoke(app, ["card"]).stdout, "the card is public and the token is not"
|
|
|
|
revoked = json.loads(runner.invoke(app, ["holder-revoke", "wife"]).stdout)
|
|
assert revoked["holder_id"] == "wife" and revoked["token_issued_at"] is None
|
|
assert HolderStore(Database(settings)).verify_token(issued["token"]) is None
|
|
|
|
|
|
def test_the_card_is_read_back_from_the_store_rather_than_written_by_hand(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
settings = Settings(home=tmp_path / "home", default_holder="husband")
|
|
monkeypatch.setattr(cli_module, "get_settings", lambda: settings)
|
|
runner = CliRunner()
|
|
store = Store(settings)
|
|
store.wife_remembers(HOME_KEYS)
|
|
store.husband_remembers(WORK_KEYS)
|
|
store.register("neighbour", name="邻居", knows_about=["weather"])
|
|
|
|
answer = runner.invoke(app, ["card"])
|
|
assert answer.exit_code == 0, answer.stdout
|
|
card = json.loads(answer.stdout)
|
|
assert card["schema_version"] == SCHEMA_VERSION
|
|
assert card["atoms"] == 2 and card["sources"] == 2
|
|
assert card["modalities"] == {"text": 2} and card["has_audio"] is False
|
|
assert card["mounts"] == {"mounted": [], "scoped": False, "atoms_in_scope": 0}
|
|
|
|
named = {entry["holder_id"]: entry for entry in card["holders"]}
|
|
assert named["wife"]["memories"] == 1 and named["wife"]["scenarios"] == ["home"]
|
|
assert named["wife"]["newest_memory"], "a caller needs to know how stale the answer would be"
|
|
assert named["neighbour"]["memories"] == 0 and named["neighbour"]["knows_about"] == ["weather"]
|
|
assert HOME_KEYS not in answer.stdout, "the card says what a holder holds, never what they hold it to be"
|