Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f46ccbae1c | |||
| 772fad18b4 | |||
| 8199774405 | |||
| 66ba06875e | |||
| 25ccb5c75b | |||
| 22e7a1b0e7 |
@@ -119,10 +119,14 @@ interpreted.
|
|||||||
`describe_store` advertises ONLY what is implemented: `relational_edges_supported=False`,
|
`describe_store` advertises ONLY what is implemented: `relational_edges_supported=False`,
|
||||||
`atomic_supersede_supported=False`, `transaction_supported=False`,
|
`atomic_supersede_supported=False`, `transaction_supported=False`,
|
||||||
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`,
|
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`,
|
||||||
**`sortable_chunk_fields=[{"name": "updated_at"}]`** (the ONLY globally-sortable field;
|
**`sortable_chunk_fields=[{"name": "updated_at", "type": "timestamp"}]`** (the ONLY
|
||||||
gates `scan`'s sort at the bifrost dispatch `_validate_scan_sort` AND Worldtree's #349
|
globally-sortable field; gates `scan`'s sort at the bifrost dispatch `_validate_scan_sort`
|
||||||
person-prime Branch-A `"updated_at" in caps.sort_fields_supported` — advertising it is
|
AND Worldtree's #349 person-prime Branch-A `"updated_at" in caps.sort_fields_supported` —
|
||||||
what lights up turn-1 durable-fact injection).
|
advertising it is what lights up turn-1 durable-fact injection). Both `name` AND `type`
|
||||||
|
are REQUIRED by the bifrost `handshake_response` `SortableChunkField` schema
|
||||||
|
(`additionalProperties:false`) — omitting `type` fails wire-schema validation and breaks
|
||||||
|
the ENTIRE handshake (memory + affect bind), not just the sort; `type` is advisory-only
|
||||||
|
(the wire never interprets it).
|
||||||
(`transaction_supported` is the bifrost **wire-level** multi-op transaction
|
(`transaction_supported` is the bifrost **wire-level** multi-op transaction
|
||||||
capability — NOT our internal SQLite transactions, which we use for atomic
|
capability — NOT our internal SQLite transactions, which we use for atomic
|
||||||
batches.) The client gates the gated verbs off these.
|
batches.) The client gates the gated verbs off these.
|
||||||
@@ -145,6 +149,20 @@ interpreted.
|
|||||||
`updated_at desc`), matching bifrost's cross-pagination conformance negative. The sort
|
`updated_at desc`), matching bifrost's cross-pagination conformance negative. The sort
|
||||||
field is indexed (`json_extract(record_json, '$.updated_at')`) so the read stays within
|
field is indexed (`json_extract(record_json, '$.updated_at')`) so the read stays within
|
||||||
person-prime's 500 ms fail-open budget.
|
person-prime's 500 ms fail-open budget.
|
||||||
|
- **Cursor is v1-provisional (KNOWN DEVIATION — offset, not snapshot).** The cursor is a
|
||||||
|
bare integer offset into the re-derived global order. This is CORRECT and conformant for
|
||||||
|
the **single-page** person-prime call (`cursor=None`), which is the only shipped consumer.
|
||||||
|
It **diverges from bifrost's protocol snapshot-cursor contract on multi-page continuation**:
|
||||||
|
the dispatch engine (`bifrost.memory` scan branch) drops the `sort` arg on a cursor
|
||||||
|
continuation because "the cursor's snapshotted order is authoritative", and maps
|
||||||
|
`ScanCursorExpired → 410`. Our offset cursor (a) does NOT snapshot the order — a page taken
|
||||||
|
after a concurrent write can duplicate/drop rows relative to the first page (heid-bug-hunt
|
||||||
|
2026-07-15, all 3 arms), and (b) never raises `ScanCursorExpired`. The `global_before_paginate`
|
||||||
|
/ cursor test asserts **static-store** behavior only. The durable/conformant fix is to adopt
|
||||||
|
the reference `InMemoryMemoryStore`'s snapshot-cursor semantics (opaque token + frozen ordered
|
||||||
|
id-list + TTL + `ScanCursorExpired`); DEFERRED pending bifrost-dev's ruling on the conformance
|
||||||
|
gap (scan/cursor has NO conformance coverage today, so a non-snapshot cursor passes). Routed
|
||||||
|
to bifrost-dev 2026-07-15.
|
||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
|
|||||||
+20
-4
File diff suppressed because one or more lines are too long
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.20.12"
|
version = "0.20.14"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ def _validate_injection(record: dict) -> None:
|
|||||||
raise InvalidArguments("injection_source only valid for injected_context origin")
|
raise InvalidArguments("injection_source only valid for injected_context origin")
|
||||||
|
|
||||||
|
|
||||||
_SORTABLE_CHUNK_FIELDS: list[dict] = [{"name": "updated_at"}]
|
# bifrost handshake_response SortableChunkField requires BOTH name + type
|
||||||
|
# (additionalProperties:false); omitting `type` fails wire-schema validation and
|
||||||
|
# breaks the whole handshake. `type` is advisory-only (the wire never interprets it).
|
||||||
|
_SORTABLE_CHUNK_FIELDS: list[dict] = [{"name": "updated_at", "type": "timestamp"}]
|
||||||
_SORTABLE_FIELD_NAMES = frozenset(f["name"] for f in _SORTABLE_CHUNK_FIELDS)
|
_SORTABLE_FIELD_NAMES = frozenset(f["name"] for f in _SORTABLE_CHUNK_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
@@ -383,6 +386,8 @@ class RatatoskrMemoryStore:
|
|||||||
scope_all = scope_all or {}
|
scope_all = scope_all or {}
|
||||||
scope_any = scope_any or []
|
scope_any = scope_any or []
|
||||||
_validate_scope(scope_all, scope_any) # PRE-002 (same lattice as search)
|
_validate_scope(scope_all, scope_any) # PRE-002 (same lattice as search)
|
||||||
|
if sort is not None and not isinstance(sort, dict): # PRE-003: malformed sort => reject, never crash
|
||||||
|
raise InvalidArguments("sort must be an object with field and direction")
|
||||||
field = (sort or {}).get("field", "updated_at")
|
field = (sort or {}).get("field", "updated_at")
|
||||||
direction = (sort or {}).get("direction", "desc")
|
direction = (sort or {}).get("direction", "desc")
|
||||||
if field not in _SORTABLE_FIELD_NAMES or direction not in ("asc", "desc"): # PRE-003
|
if field not in _SORTABLE_FIELD_NAMES or direction not in ("asc", "desc"): # PRE-003
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ def test_fresh_db_advertises_v1_caps_and_schema():
|
|||||||
assert caps["atomic_supersede_supported"] is False
|
assert caps["atomic_supersede_supported"] is False
|
||||||
assert caps["transaction_supported"] is False
|
assert caps["transaction_supported"] is False
|
||||||
assert caps["filterable_metadata_fields"] == []
|
assert caps["filterable_metadata_fields"] == []
|
||||||
|
# bifrost handshake_response SortableChunkField requires BOTH name + type
|
||||||
|
# (additionalProperties:false) — omitting `type` fails wire-schema validation and
|
||||||
|
# breaks the ENTIRE Bifrost bind (regression guard: the deploy-breaker of 2026-07-15).
|
||||||
|
scf = caps["sortable_chunk_fields"]
|
||||||
|
assert scf == [{"name": "updated_at", "type": "timestamp"}]
|
||||||
|
for entry in scf:
|
||||||
|
assert set(entry) == {"name", "type"} # required exactly, no extra keys
|
||||||
|
assert isinstance(entry["name"], str) and entry["name"]
|
||||||
|
assert isinstance(entry["type"], str) and entry["type"]
|
||||||
# tables + vec index queryable
|
# tables + vec index queryable
|
||||||
store._conn.execute("SELECT * FROM memory_chunks")
|
store._conn.execute("SELECT * FROM memory_chunks")
|
||||||
store._conn.execute("SELECT * FROM memory_idempotency")
|
store._conn.execute("SELECT * FROM memory_idempotency")
|
||||||
@@ -427,6 +436,14 @@ async def test_scan_unadvertised_sort_field_rejected():
|
|||||||
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort={"field": "salience", "direction": "desc"})
|
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort={"field": "salience", "direction": "desc"})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scan_non_dict_sort_rejected():
|
||||||
|
# PRE-003: a truthy non-dict sort (caller-controlled) -> InvalidArguments, never AttributeError.
|
||||||
|
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
|
||||||
|
for bad in ("updated_at", ["updated_at"], 5):
|
||||||
|
with pytest.raises(InvalidArguments):
|
||||||
|
await store.scan(scope_all={"end_user": "u1"}, limit=3, sort=bad)
|
||||||
|
|
||||||
|
|
||||||
async def test_scan_records_carry_person_prime_filter_fields():
|
async def test_scan_records_carry_person_prime_filter_fields():
|
||||||
# The client _scan_filter_matches keys on agent_id + subject + worldtree_scope; a record
|
# The client _scan_filter_matches keys on agent_id + subject + worldtree_scope; a record
|
||||||
# missing any is silently dropped -> the scan record must carry them verbatim.
|
# missing any is silently dropped -> the scan record must carry them verbatim.
|
||||||
|
|||||||
Reference in New Issue
Block a user