Skip to content

Commit 3a47efb

Browse files
author
Bryce
committed
fix(phase9 #97 D10.e): enforce §C.3 explicit error contract in decode_cursor
Per Weston msg=cc4a3ab0 二线 CR blocker: decode_cursor() previously surfaced malformed / wrong-schema / expired wire payloads as bare ValueError / KeyError, leaving every D10.c / D10.d caller to re-derive the canonical mapping. That violates the §C.3 explicit-not-silent invariant by construction — any forgotten mapping silently degrades into ValueError → tool error → first-page restart, which is exactly the anti-pattern SILENT_RESET_FORBIDDEN guards against. Fix: - decode_cursor() now raises CursorError directly with the right canonical code: cursor_invalid (malformed wire / base64 / json / missing field), cursor_schema_unsupported (unknown schema_version), cursor_expired (past issued_at + ttl_seconds clock). - _decode_cursor_payload() preserved as a private structural-only decode for tests that need to craft expired / wrong-schema payloads to exercise the canonical error paths. - 3 new canonical-code tests + 1 internal-decode escape hatch test added; old raw-error test deleted (pre-#1710 wire shape no longer reachable through public surface). _payload() fixture's issued_at now defaults to current time so round-trip tests stay green when run far from the fixture's drafting date; tests that need expired / fixed payloads override explicitly. 21/21 tests pass; ruff check + format clean.
1 parent d07c4c4 commit 3a47efb

2 files changed

Lines changed: 112 additions & 28 deletions

File tree

aperag/mcp/cursor/codec.py

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@
3131
from __future__ import annotations
3232

3333
import base64
34+
import binascii
3435
import json
3536
import time
3637
from dataclasses import asdict, dataclass, field
3738
from typing import Any
3839

40+
from aperag.mcp.cursor.errors import CursorError
41+
3942
# CURSOR_SCHEMA_VERSION bumps when the on-wire payload shape changes
4043
# in an incompatible way. Decoders treat any `schema_version` they
4144
# don't know about as `cursor_schema_unsupported` (§C.3) — pending
@@ -81,26 +84,76 @@ def _b64url_decode(token: str) -> bytes:
8184
return base64.urlsafe_b64decode(token + pad)
8285

8386

84-
def decode_cursor(token: str) -> CursorPayload:
85-
"""Decode a wire cursor string back to a CursorPayload.
87+
def decode_cursor(token: str, *, now: int | None = None) -> CursorPayload:
88+
"""Decode a wire cursor string and enforce the §C.3 contract.
89+
90+
The decoder is the single chokepoint where every caller-facing
91+
cursor failure becomes one of the six canonical
92+
:class:`CursorError` codes — pushing the mapping out to each
93+
tool would invite silent-reset drift (Weston msg=cc4a3ab0).
94+
95+
Raises:
96+
CursorError: with code ``cursor_invalid`` (malformed wire /
97+
base64 / JSON / missing field), ``cursor_schema_unsupported``
98+
(unrecognised ``schema_version``), or ``cursor_expired``
99+
(past ``issued_at + ttl_seconds`` clock).
100+
101+
Tool-boundary callers that need the raw structural decode
102+
without the schema/expiry checks (typically only the test
103+
surface) should use :func:`_decode_cursor_payload` directly.
104+
"""
86105

87-
On malformed / unsupported / expired input the caller is
88-
responsible for raising the canonical CursorError code
89-
(``cursor_invalid`` / ``cursor_schema_unsupported`` / etc) —
90-
this codec only surfaces structural issues via ValueError /
91-
KeyError; the canonical error mapping lives in
92-
:mod:`aperag.mcp.cursor.errors` (pending spec amendment lock).
106+
payload = _decode_cursor_payload(token)
107+
108+
if payload.schema_version != CURSOR_SCHEMA_VERSION:
109+
raise CursorError(
110+
"cursor_schema_unsupported",
111+
f"cursor schema_version {payload.schema_version} not supported by this server",
112+
details={
113+
"received_schema_version": payload.schema_version,
114+
"supported_schema_version": CURSOR_SCHEMA_VERSION,
115+
},
116+
)
117+
118+
if payload.is_expired(now=now):
119+
raise CursorError(
120+
"cursor_expired",
121+
"cursor has passed its TTL window",
122+
details={
123+
"issued_at": payload.issued_at,
124+
"ttl_seconds": payload.ttl_seconds,
125+
},
126+
)
127+
128+
return payload
129+
130+
131+
def _decode_cursor_payload(token: str) -> CursorPayload:
132+
"""Structural decode without §C.3 schema/expiry checks.
133+
134+
Internal helper — public callers should always go through
135+
:func:`decode_cursor` so the explicit-not-silent contract is
136+
enforced uniformly. This raw form exists so the test suite can
137+
construct expired or wrong-schema payloads to exercise the
138+
canonical error paths.
93139
"""
94140

95-
raw = _b64url_decode(token)
96-
obj = json.loads(raw)
97-
return CursorPayload(
98-
schema_version=obj["schema_version"],
99-
sort_key=obj["sort_key"],
100-
last_position=obj["last_position"],
101-
invariant_hash=obj["invariant_hash"],
102-
issued_at=obj["issued_at"],
103-
ttl_seconds=obj.get("ttl_seconds", DEFAULT_TTL_SECONDS),
104-
server_id=obj["server_id"],
105-
extra=obj.get("extra", {}),
106-
)
141+
try:
142+
raw = _b64url_decode(token)
143+
obj = json.loads(raw)
144+
return CursorPayload(
145+
schema_version=obj["schema_version"],
146+
sort_key=obj["sort_key"],
147+
last_position=obj["last_position"],
148+
invariant_hash=obj["invariant_hash"],
149+
issued_at=obj["issued_at"],
150+
ttl_seconds=obj.get("ttl_seconds", DEFAULT_TTL_SECONDS),
151+
server_id=obj["server_id"],
152+
extra=obj.get("extra", {}),
153+
)
154+
except (binascii.Error, ValueError, KeyError, TypeError) as exc:
155+
raise CursorError(
156+
"cursor_invalid",
157+
"cursor wire payload could not be decoded",
158+
details={"reason": str(exc) or exc.__class__.__name__},
159+
) from exc

tests/unit_test/mcp/test_cursor_contract.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
decode_cursor,
3434
encode_cursor,
3535
)
36-
from aperag.mcp.cursor.codec import CURSOR_SCHEMA_VERSION, DEFAULT_TTL_SECONDS
36+
from aperag.mcp.cursor.codec import (
37+
CURSOR_SCHEMA_VERSION,
38+
DEFAULT_TTL_SECONDS,
39+
_decode_cursor_payload,
40+
)
3741
from aperag.mcp.cursor.errors import (
3842
SILENT_RESET_FORBIDDEN,
3943
CursorError,
@@ -42,11 +46,17 @@
4246

4347

4448
def _payload(**overrides) -> CursorPayload:
49+
# ``issued_at`` defaults to "now" so round-trip tests don't trip
50+
# the §C.4 TTL window when the test is run far from the fixture's
51+
# original drafting date. Tests that need a frozen / expired
52+
# payload override ``issued_at`` explicitly.
53+
import time as _time
54+
4555
base = dict(
4656
sort_key="created_at",
4757
last_position={"created_at": "2026-04-26T03:00:00Z", "id": "doc-42"},
4858
invariant_hash="a" * 64,
49-
issued_at=1761465600,
59+
issued_at=int(_time.time()),
5060
server_id="srv-test",
5161
)
5262
base.update(overrides)
@@ -71,19 +81,40 @@ def test_default_schema_version_and_ttl_match_spec(self):
7181
assert payload.schema_version == CURSOR_SCHEMA_VERSION == 1
7282
assert payload.ttl_seconds == DEFAULT_TTL_SECONDS == 3600
7383

74-
def test_decode_rejects_garbage_via_value_error(self):
75-
# codec layer surfaces structural failures as ValueError /
76-
# JSONDecodeError; the canonical wire mapping into
77-
# `cursor_invalid` happens at the tool boundary so the codec
78-
# itself stays insulated from the error code naming.
79-
with pytest.raises((ValueError, KeyError)):
84+
def test_decode_garbage_raises_canonical_cursor_invalid(self):
85+
# The decoder is the single chokepoint — every caller
86+
# downstream sees `cursor_invalid` for a malformed wire
87+
# without each tool reinventing the mapping.
88+
with pytest.raises(CursorError) as excinfo:
8089
decode_cursor("not~~base64??")
90+
assert excinfo.value.code == "cursor_invalid"
91+
92+
def test_decode_unknown_schema_version_raises_cursor_schema_unsupported(self):
93+
future_token = encode_cursor(_payload(schema_version=CURSOR_SCHEMA_VERSION + 1))
94+
with pytest.raises(CursorError) as excinfo:
95+
decode_cursor(future_token)
96+
assert excinfo.value.code == "cursor_schema_unsupported"
97+
assert excinfo.value.details["received_schema_version"] == CURSOR_SCHEMA_VERSION + 1
98+
99+
def test_decode_past_ttl_raises_cursor_expired(self):
100+
token = encode_cursor(_payload(issued_at=1000, ttl_seconds=60))
101+
with pytest.raises(CursorError) as excinfo:
102+
decode_cursor(token, now=2000)
103+
assert excinfo.value.code == "cursor_expired"
81104

82105
def test_is_expired_at_exact_ttl_boundary(self):
83106
payload = _payload(issued_at=1000, ttl_seconds=60)
84107
assert payload.is_expired(now=1059) is False
85108
assert payload.is_expired(now=1060) is True
86109

110+
def test_internal_decode_skips_schema_and_expiry_checks(self):
111+
# _decode_cursor_payload is the test/debug escape hatch — it
112+
# MUST NOT be called from production code, only from tests
113+
# that need to craft wrong-schema or expired payloads.
114+
future_token = encode_cursor(_payload(schema_version=999))
115+
payload = _decode_cursor_payload(future_token)
116+
assert payload.schema_version == 999
117+
87118

88119
class TestInvariantHash:
89120
def test_deterministic_across_dict_ordering(self):

0 commit comments

Comments
 (0)