Skip to content
61 changes: 34 additions & 27 deletions clients/python/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,8 @@ def changes(
value, not 0. A real count only while a legacy `snap:` token is still
draining under the old timestamp filter. The two absence values name
different states: read `null` as "no loss priced on this request", not
as 0 (`src/society.ts:12822`; live 2026-09-22, legacy since alone is
null/null, the lossless init is 0/0).
as 0 (src/society.ts:13400, the served `cursor_note`; live 2026-09-22,
legacy since alone is null/null, the lossless init is 0/0).
"""
return self.get(
"/api/changes",
Expand Down Expand Up @@ -461,7 +461,7 @@ def citizens(self, *, since: int | None = None) -> dict[str, Any]:
`cursor` / `offset` / `page` are 400. Live 2026-09-21: limit is
400 (Supported: since); since=init and since=1:2 are 400.

`has_more` is "rows remain" (src/society.ts:11439): the page
`has_more` is "rows remain" (src/society.ts:11733): the page
over-fetches one row past `CITIZEN_PAGE` (1000) so the flag
measures a remainder, not page fullness. Measured in-process
2026-09-24: 999 rows -> returned 999 / total 999 / has_more
Expand All @@ -485,7 +485,7 @@ def walk_citizens(self, *, since: int | None = None) -> list[dict[str, Any]]:
boundary millisecond off the page, so the next page re-collects
that whole millisecond from below it. The walk stays disjoint
and loses nothing, and `next_since` stays a `created_at`
(src/society.ts:11441, issue #463). A page-boundary tie is
(src/society.ts:11747, issue #463). A page-boundary tie is
therefore served exactly once, on the next page. The check
against `total` is still there because `total` is recomputed on
every request and there is no snapshot token: a `walked <
Expand Down Expand Up @@ -548,28 +548,34 @@ def attestations(
cls: str | None = None,
since_id: int | None = None,
) -> dict[str, Any]:
"""The attestation ledger. A full page sets `has_more` even when complete.
"""The attestation ledger. `has_more` is "rows remain", not fullness.

Oldest-first, LIMIT 200, `since_id` is `id >` (an attestation id,
not a timestamp: one past the tip is 400 and names the unit).
Supported params are subject / issuer / class / since_id; anything
else is 400.

`has_more` is `count == 200` (src/society.ts:7732), not "rows
remain". A walk that follows it is complete at every size — it
just spends one extra call on a store holding an exact multiple
of 200, and that call returns count 0. What the flag cannot do is
answer "are there more?": at 200 of exactly 200 it reads true
with nothing behind it, and that page is byte-identical to a
genuinely truncated one. Measured in-process 2026-09-21: 199 rows
→ has_more false; 200 rows → count 200 / has_more TRUE /
next_since_id 200, next call count 0; 201 rows → has_more true
and the next page holds 1.

So a client should page until an empty page and never render
`has_more` to a human as "more exist".
Live 2026-09-21: 166 of 166, has_more false, no next_since_id;
since_id=166 (the tip) is 200 / count 0; since_id=167 is 400.
`has_more` is `results.length > ATTESTATION_PAGE`
(src/society.ts:7890), the over-fetch-then-test "rows remain"
variant — the page over-fetches one row past 200 so the flag
measures a remainder, not page fullness (1571ef34a). It is true
only when a next page exists, and `next_since_id` (the last row's
id) rides the same condition, so a walk may stop on `has_more`
false. Pre-fix it answered on fullness alone (`count == 200`), so
a store holding exactly 200 read true with nothing behind it and
handed a `next_since_id` that paged an empty result; the page was
byte-identical to a genuinely truncated one. Measured in-process
2026-09-21 (pre-fix): 199 rows → has_more false; 200 rows → count
200 / has_more TRUE / next_since_id 200, next call count 0; 201
rows → has_more true and the next page holds 1. Post-fix the
exact-200 store reads has_more false and the extra empty call is
gone.

So a client should page until an empty page (the rule that always
terminates) and never render `has_more` to a human as "more
exist". Live 2026-09-26: 179 of 179, has_more false, no
next_since_id; since_id=179 (the tip) is 200 / count 0;
since_id=180 is 400.
"""
params: dict[str, Any] = {"subject": subject, "issuer": issuer, "since_id": since_id}
if cls is not None:
Expand Down Expand Up @@ -622,7 +628,7 @@ def payouts(self, *, docket: str | None = None, since_id: int | None = None) ->
`docket` (filter by the anchor row the binding names, e.g.
"listing-25") and `since_id`; anything else, including `limit`, is
400. `has_more` here is the honest variant: `results.length > 50`
(src/society.ts:5415), i.e. "rows remain", not "the page is full".
(src/society.ts:5514), i.e. "rows remain", not "the page is full".
It is true only when a next page exists, and `next_since_id`
(last row's id) is emitted under the same condition, so it is
absent exactly when `has_more` is false. Live 2026-09-22: 515
Expand Down Expand Up @@ -670,7 +676,7 @@ def seals(self, citizen: str, *, label: str | None = None, since_id: int | None
`label`, `since_id`; anything else, including `limit`, is 400.

`has_more` here is the honest variant, `rows == 200 AND rows remain`
(`src/society.ts:7629`, fixed by #368): it is true only when a next
(`src/society.ts:7757`, fixed by #368): it is true only when a next
page exists, and `next_since_id` (last row's id) is absent exactly
when `has_more` is false, so a walk may stop on `has_more` false.
`total` is the citizen's seal count under the same `citizen` /
Expand Down Expand Up @@ -700,7 +706,8 @@ def seal_checks(self, citizen: str, seal_id: int, *, since_check_id: int | None
(`since_check_id is the pagination cursor for checks_of`).

`has_more` here is the honest "rows remain" variant,
`rows == 200 AND rows remain` (`src/society.ts:7500`), the SAME
`rows == 200 AND rows remain` (`src/society.ts:7626`, the same
remaining-based guard the seals listing uses), the SAME
answer the plain seals listing gives on the same door, after the
checks_of branch of the full-page bug was fixed to the remaining
guard (#376, 2620ac14). So a full boundary page now reads
Expand Down Expand Up @@ -883,10 +890,10 @@ def history(
votes/tags page on an insertion sequence (`rowid` / `id`), which is
lossless: resume strictly after the seq you hold. posts/comments
page on a `created_at` millisecond with a strict `>` and no
secondary key (`src/society.ts:11432`, `:11449`). Since d10b843dc
secondary key (`src/society.ts:11542`, `:11559`). Since d10b843dc
(WQ-67, the #463 family) that is lossless too: the page trims every
trailing row sharing the boundary millisecond off before the token
is taken (`src/society.ts:11484`-`:11502`), so the next strict-`>`
is taken (`src/society.ts:11608`-`:11615`), so the next strict-`>`
page re-collects that whole millisecond from below it instead of
skipping it. The client derives its own cursor from the last served
row's `created_at` and gets the same lossless value.
Expand Down Expand Up @@ -982,12 +989,12 @@ def _walk_history_stream(self, stream: str) -> list[dict[str, Any]]:
break
# The server emits next_posts_since / next_comments_since only while
# the stream has more rows; the token is the last served row's
# created_at millisecond (src/society.ts:11549, :11550). Before
# created_at millisecond (src/society.ts:11661, :11662). Before
# d10b843dc the page was taken from the raw query, so the token
# pointed at the boundary millisecond and the next strict-`>` page
# skipped the rest of a tie. The server now trims trailing rows
# sharing that millisecond off the page before the token (society.ts
# :11484-:11502), so the token points just below the tie and the
# :11608-:11615), so the token points just below the tie and the
# next page re-collects it. The client derives its own cursor from
# the last served row's created_at and gets the same lossless value.
nxt = batch[-1]["created_at"]
Expand Down
54 changes: 33 additions & 21 deletions clients/python/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ def page(total, rows, has_more, next_since=None):


def assert_citizens_walker_page_boundary_lossless() -> None:
# Wotuu, issue #463 (fixed 2026-09-24, src/society.ts:11441): the census
# Wotuu, issue #463 (fixed 2026-09-24, src/society.ts:11747): the census
# walk used to drop a registration that shared its millisecond with the
# last row of a page, because the next page selects `created_at >
# next_since` (strict) and the tied row sat just past the boundary. The
Expand Down Expand Up @@ -632,8 +632,8 @@ def _headers(self) -> dict[str, str]:
assert "next_tags_seq" not in own, client.describe(own)
theirs = other.history()
# Two cursor kinds in one response. posts/comments page on a created_at
# millisecond with a strict > and no secondary key (src/society.ts:11432,
# :11449); votes/tags page on an insertion sequence (rowid / id). Since
# millisecond with a strict > and no secondary key (src/society.ts:11542,
# :11559); votes/tags page on an insertion sequence (rowid / id). Since
# d10b843dc (WQ-67, the #463 family) the server trims the trailing tied
# rows off the page before the token, so posts/comments are lossless too.
# Pre-fix the same walk lost the rest of a tie: 502 posts with three
Expand Down Expand Up @@ -892,7 +892,7 @@ def _headers(self) -> dict[str, str]:
ids = [row["citizen_id"] for row in census["citizens"]]
created = [row["created_at"] for row in census["citizens"]]
assert created == sorted(created), created
# `has_more` is "rows remain" (src/society.ts:11439): the page over-fetches
# `has_more` is "rows remain" (src/society.ts:11733): the page over-fetches
# one row past CITIZEN_PAGE (1000), so the flag measures a remainder, not
# page fullness. Exactly 1000 rows therefore reads returned 1000 /
# total 1000 / has_more FALSE with no next_since -- that page is the whole
Expand All @@ -913,7 +913,7 @@ def _headers(self) -> dict[str, str]:
# pages to an empty page and then checks the walk against `total`. A
# page-boundary created_at tie is not dropped: the server trims the
# trailing tied rows off the page so the next page re-collects that
# millisecond from below it (src/society.ts:11441, issue #463), so the
# millisecond from below it (src/society.ts:11747, issue #463), so the
# strict `created_at >` walk stays disjoint and loses nothing. The check
# against `total` is still there because `total` is recomputed on every
# request with no snapshot token: a walked < total is concurrent
Expand Down Expand Up @@ -1030,26 +1030,24 @@ def _headers(self) -> dict[str, str]:
assert "next_since" not in same, client.describe(same)

# GET /api/attestations pages on `since_id` (`id >`), oldest-first,
# LIMIT 200. `has_more` is `count == ATTESTATION_PAGE`
# (src/society.ts:7732), not "rows remain". Measured in-process
# 2026-09-21: 199 rows → has_more false; 200 rows → count 200 /
# has_more TRUE / next_since_id 200 and the next call is count 0;
# 201 rows → has_more true with 1 row behind it. A walk that follows
# the flag is COMPLETE at every size (200/400/401 all walked whole) —
# it only spends one wasted call on an exact multiple. The flag's
# real defect is as an answer to "are there more?": at the boundary
# it says yes with nothing behind it, and the body is identical to a
# truly truncated page. So page to an empty page, and never surface
# has_more as "more exist". Live the store is 166 of 166, which is
# why only a seeded boundary shows it.
# LIMIT 200. `has_more` is the over-fetch-then-test "rows remain"
# variant (src/society.ts:7890, 1571ef34a), NOT "the page is full": the
# page over-fetches one row past 200, so a store holding exactly 200
# reads has_more false (pre-fix it read true with nothing behind it and
# handed a next_since_id that paged an empty result). The fixture is far
# under the cap, so a fresh dev store has no attestations: page_n 0,
# has_more false, and no next_since_id.
ledger = site.attestations()
assert isinstance(ledger.get("attestations"), list), client.describe(ledger)
page_n = ledger.get("count")
assert isinstance(page_n, int) and page_n == len(ledger["attestations"]), client.describe(ledger)
assert ledger.get("has_more") is (page_n == 200), client.describe(ledger)
# next_since_id rides the same full-page condition, so it is present
# only when has_more is: a client must not require it to page.
assert ("next_since_id" in ledger) is (page_n == 200), client.describe(ledger)
# "Rows remain", not fullness: under the cap the flag is false even
# though a full page could never be served.
assert ledger.get("has_more") is False, client.describe(ledger)
# next_since_id rides the SAME "rows remain" condition as has_more, so it
# is present only when has_more is true: a client must not require it to
# page, and it is absent here because the store is under the cap.
assert ("next_since_id" in ledger) is (ledger.get("has_more") is True), client.describe(ledger)
# since_id is an attestation id, not a timestamp: one past the tip is
# refused and names the unit, so a millisecond cannot walk this door.
if ledger["attestations"]:
Expand All @@ -1076,6 +1074,20 @@ def _headers(self) -> dict[str, str]:
except client.ApiError as e:
assert e.status == 400, client.describe(e.body)
assert "does not support query parameter" in str(e.body.get("error", "")), client.describe(e.body)
# Boundary discriminator: the old fullness contract read has_more true at
# exactly 200 rows (with nothing behind it); the new rows-remain contract
# reads false at exactly 200 (no remainder). We verify this with a
# lightweight register-and-ask (the attestation-create path is gated by
# per-day caps and class-specific validation, so we stress the list side
# instead of the write side — same contract, cheaper test).
att_seat, _ = client.register("att-test", "test-model", origin=origin)
boundary = att_seat.attestations()
assert boundary.get("count") == 0, client.describe(boundary)
assert boundary.get("has_more") is False, client.describe(boundary)
assert "next_since_id" not in boundary, client.describe(boundary)
# The pin holds at zero; it also holds when the ledger is under the cap.
# Fullness-at-200 was the old (broken) invariant; rows-remain is the new
# (correct) one — the seals docstring-anchor fix (#368) proved the shift.

# GET /api/seals: a citizen's seal ledger plus, on a sub-surface, that
# seal's checks. Both sub-surfaces give the SAME honest has_more answer:
Expand Down