mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
30b590fb25
The coordinator memory scope was keyed by the session's ws_id, so every new coordinator session started with an empty namespace and its rows were orphaned on close — coordinator memory never actually persisted. Re-key the scope to the coordinator's creator user_id: one durable orchestration namespace per user, shared by all of that user's coordinator sessions (concurrent ones included; upsert-by-name is the collision rule). The child-containment threat model is unchanged: the gate is session KIND — children are always interactive and share the parent's user_id, so _validate_scope rejects them before scope resolution, and the REST memories API still rejects the coordinator scope outright. The implicit visibility lane now also fails closed on an empty scope_id to match the explicit search/list lanes (the storage helpers treat a falsy scope_id as 'no scope_id filter', which would have read every user's rows). Anonymous coordinators are no longer constructible: ChatSession refuses kind=COORDINATOR with an empty user_id at the constructor — the single choke point covering create, rehydration of legacy rows (surfaced by the open handler as a 503 with remediation text), and any future host — and the console no longer masks an empty uid as a phantom 'system' principal when minting coordinator JWTs, per CoordinatorTokenManager's documented 'sub = the real creator user_id' contract. Migration 061 carries existing coordinator rows across: rows whose owning workstream is gone or ownerless are deleted (unreachable under user keying), same-name collisions within a user keep the newest updated row (memory_id tiebreak), and survivors re-key to the owner's user_id.
215 lines
8.0 KiB
Python
215 lines
8.0 KiB
Python
"""Tests for alembic migration 061 (coordinator memories re-keyed to user_id).
|
|
|
|
Drives ``command.upgrade`` from a programmatic Alembic config against an
|
|
isolated SQLite database per test (the 060-test harness pattern), then
|
|
asserts:
|
|
|
|
* a ``scope='coordinator'`` row keyed by a live coordinator's ws_id is
|
|
re-keyed to that workstream's owner ``user_id``;
|
|
* rows that cannot be attributed — scope_id matching no workstreams row, or
|
|
matching one whose ``user_id`` is NULL/empty — are deleted (documented
|
|
lossy step);
|
|
* name collisions that would violate ``uq_smem_name_scope`` after the re-key
|
|
(same name, two coordinator sessions of the same user) keep only the
|
|
newest ``updated`` row, with ``memory_id`` as the deterministic tiebreak;
|
|
* the same name under two DIFFERENT users' coordinators survives as two
|
|
rows (one per user namespace);
|
|
* non-coordinator scopes are untouched, including a ``user``-scope row whose
|
|
scope_id equals an owner user_id (the post-rekey value collision the
|
|
scope column is meant to keep disjoint).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
_MIGRATIONS_DIR = str(
|
|
Path(__file__).resolve().parent.parent / "turnstone" / "core" / "storage" / "migrations"
|
|
)
|
|
|
|
|
|
def _alembic_cfg(db_path: Path) -> Config:
|
|
cfg = Config()
|
|
cfg.set_main_option("script_location", _MIGRATIONS_DIR)
|
|
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
|
|
return cfg
|
|
|
|
|
|
def _seed_ws(conn: sa.Connection, ws_id: str, user_id: object, kind: str = "coordinator") -> None:
|
|
conn.execute(
|
|
sa.text(
|
|
"INSERT INTO workstreams (ws_id, user_id, kind, created, updated) "
|
|
"VALUES (:ws_id, :user_id, :kind, '2026-06-01T00:00:00', '2026-06-01T00:00:00')"
|
|
),
|
|
{"ws_id": ws_id, "user_id": user_id, "kind": kind},
|
|
)
|
|
|
|
|
|
def _seed_memory(
|
|
conn: sa.Connection,
|
|
memory_id: str,
|
|
name: str,
|
|
scope: str,
|
|
scope_id: str,
|
|
updated: str = "2026-06-01T00:00:00",
|
|
) -> None:
|
|
conn.execute(
|
|
sa.text(
|
|
"INSERT INTO structured_memories "
|
|
"(memory_id, name, scope, scope_id, content, created, updated) "
|
|
"VALUES (:memory_id, :name, :scope, :scope_id, :content, :created, :updated)"
|
|
),
|
|
{
|
|
"memory_id": memory_id,
|
|
"name": name,
|
|
"scope": scope,
|
|
"scope_id": scope_id,
|
|
"content": f"content of {memory_id}",
|
|
"created": "2026-06-01T00:00:00",
|
|
"updated": updated,
|
|
},
|
|
)
|
|
|
|
|
|
def _all_memories(engine: sa.Engine) -> dict[str, tuple[str, str, str]]:
|
|
"""memory_id -> (name, scope, scope_id) for every surviving row."""
|
|
with engine.connect() as conn:
|
|
rows = conn.execute(
|
|
sa.text("SELECT memory_id, name, scope, scope_id FROM structured_memories")
|
|
).fetchall()
|
|
return {r[0]: (r[1], r[2], r[3]) for r in rows}
|
|
|
|
|
|
class TestMigration061:
|
|
def test_rekeys_to_owner_user_id_and_leaves_other_scopes_alone(self, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "061-rekey.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "060")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_ws(conn, "coord-a", "user-1")
|
|
_seed_memory(conn, "m1", "runbook", "coordinator", "coord-a")
|
|
# Other scopes must pass through untouched — including a
|
|
# user-scope row already keyed by the SAME user_id the
|
|
# coordinator row is about to be re-keyed onto.
|
|
_seed_memory(conn, "m2", "runbook", "user", "user-1")
|
|
_seed_memory(conn, "m3", "runbook", "workstream", "coord-a")
|
|
_seed_memory(conn, "m4", "runbook", "global", "")
|
|
|
|
command.upgrade(cfg, "061")
|
|
|
|
mems = _all_memories(engine)
|
|
assert mems["m1"] == ("runbook", "coordinator", "user-1")
|
|
assert mems["m2"] == ("runbook", "user", "user-1")
|
|
assert mems["m3"] == ("runbook", "workstream", "coord-a")
|
|
assert mems["m4"] == ("runbook", "global", "")
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_deletes_unattributable_rows(self, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "061-orphans.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "060")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_ws(conn, "coord-null", None)
|
|
_seed_ws(conn, "coord-empty", "")
|
|
_seed_memory(conn, "m-gone", "a", "coordinator", "no-such-ws")
|
|
_seed_memory(conn, "m-null", "b", "coordinator", "coord-null")
|
|
_seed_memory(conn, "m-empty", "c", "coordinator", "coord-empty")
|
|
|
|
command.upgrade(cfg, "061")
|
|
|
|
assert _all_memories(engine) == {}
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_dedups_same_user_collisions_keeping_newest(self, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "061-dedup.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "060")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_ws(conn, "coord-old", "user-1")
|
|
_seed_ws(conn, "coord-new", "user-1")
|
|
_seed_ws(conn, "coord-tie", "user-1")
|
|
# Same name across two sessions of the same user — only
|
|
# the newer ``updated`` survives.
|
|
_seed_memory(
|
|
conn,
|
|
"m-old",
|
|
"plan",
|
|
"coordinator",
|
|
"coord-old",
|
|
updated="2026-06-01T00:00:00",
|
|
)
|
|
_seed_memory(
|
|
conn,
|
|
"m-new",
|
|
"plan",
|
|
"coordinator",
|
|
"coord-new",
|
|
updated="2026-06-02T00:00:00",
|
|
)
|
|
# Equal ``updated``: memory_id breaks the tie (max wins).
|
|
_seed_memory(
|
|
conn,
|
|
"m-tie-a",
|
|
"pinned",
|
|
"coordinator",
|
|
"coord-new",
|
|
updated="2026-06-03T00:00:00",
|
|
)
|
|
_seed_memory(
|
|
conn,
|
|
"m-tie-b",
|
|
"pinned",
|
|
"coordinator",
|
|
"coord-tie",
|
|
updated="2026-06-03T00:00:00",
|
|
)
|
|
# Distinct names never collide — both survive.
|
|
_seed_memory(conn, "m-keep", "notes", "coordinator", "coord-old")
|
|
|
|
command.upgrade(cfg, "061")
|
|
|
|
mems = _all_memories(engine)
|
|
assert "m-old" not in mems
|
|
assert mems["m-new"] == ("plan", "coordinator", "user-1")
|
|
assert "m-tie-a" not in mems
|
|
assert mems["m-tie-b"] == ("pinned", "coordinator", "user-1")
|
|
assert mems["m-keep"] == ("notes", "coordinator", "user-1")
|
|
finally:
|
|
engine.dispose()
|
|
|
|
def test_same_name_across_users_survives_as_two_rows(self, tmp_path: Path) -> None:
|
|
db_path = tmp_path / "061-two-users.db"
|
|
cfg = _alembic_cfg(db_path)
|
|
command.upgrade(cfg, "060")
|
|
|
|
engine = sa.create_engine(f"sqlite:///{db_path}")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_seed_ws(conn, "coord-u1", "user-1")
|
|
_seed_ws(conn, "coord-u2", "user-2")
|
|
_seed_memory(conn, "m-u1", "plan", "coordinator", "coord-u1")
|
|
_seed_memory(conn, "m-u2", "plan", "coordinator", "coord-u2")
|
|
|
|
command.upgrade(cfg, "061")
|
|
|
|
mems = _all_memories(engine)
|
|
assert mems["m-u1"] == ("plan", "coordinator", "user-1")
|
|
assert mems["m-u2"] == ("plan", "coordinator", "user-2")
|
|
finally:
|
|
engine.dispose()
|