Files
kvitt/tools/contracts-check.py
T

1569 lines
86 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Kvitt contract check (SPEC §11.2 M1 gate). Standard library only, Python 3.9+.
Checks, one output line each:
* every JSON file under contracts/ parses;
* every test-vector file matches the format of SPEC §14.5.5 and covers the minimum cases;
* a reference implementation of SPEC §9.1, §9.3, §9.4 and §14.5.2/§14.5.4 reproduces every expected value;
* every example under contracts/examples/ matches the wire shapes of SPEC §14.3, §14.4 and §14.6 by key set and type,
plus the money invariants of §9.1 / §9.4 / §14.3.3 (sums, sort orders);
* contracts/schema/server-v1.sql is byte-identical to the DDL in SPEC §14.1, loads in SQLite, has a clean
foreign_key_check and the seed rows;
* contracts/schema/migrations/0002_categories_v2.sql loads on top of server-v1.sql (foreign keys on, one
transaction, as sqlx runs it), on an empty database and on one with rows referring to categories, and leaves the
14 default categories with server_versions from the global sequence and schema_version 2;
* contracts/category-keywords.csv (the keyword table of the category suggestion) is well-formed: header, match
kinds, default keys, keywords already normalised, unique, sorted;
* contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2, and the
DefaultCategoryKey enum holds the 14 default keys (text check: the standard library has no YAML parser).
Exit status 0 when every check passes, 1 otherwise.
Usage: python3 tools/contracts-check.py [repository root]
"""
import csv
import datetime
import io
import json
import os
import re
import sqlite3
import sys
import unicodedata
ROOT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONTRACTS = os.path.join(ROOT, "contracts")
FAILURES = []
PASSES = [0]
def report(ok, name, detail=""):
if ok:
PASSES[0] += 1
print("PASS %s%s" % (name, (": " + detail) if detail else ""))
else:
FAILURES.append(name)
print("FAIL %s: %s" % (name, detail))
def rel(path):
return os.path.relpath(path, ROOT)
# ----------------------------------------------------------------------------------------------------------------------
# Reference implementation of the pure domain functions (SPEC §9.1, §9.3, §9.4, §14.5.2, §14.5.4)
# ----------------------------------------------------------------------------------------------------------------------
class DomainError(Exception):
def __init__(self, token):
Exception.__init__(self, token)
self.token = token
AMOUNT_MIN, AMOUNT_MAX = 1, 1000000000
BP_TOTAL = 10000
MAX_ALLOCATIONS = 50
def compute_split(amount_ore, payer_user_id, user_ids, spec):
"""§9.1 and §14.5.2. Errors in SplitError enum order."""
mode = spec["mode"]
# 1 amount_out_of_range
if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX):
raise DomainError("amount_out_of_range")
# 2 users_invalid
if len(user_ids) != 2 or len(set(user_ids)) != 2:
raise DomainError("users_invalid")
# 3 payer_unknown
if payer_user_id not in user_ids:
raise DomainError("payer_unknown")
if mode == "full":
bearer = spec["bearer_user_id"]
# 4-7 do not apply; 8 bearer_unknown; 9 share_users_mismatch cannot happen
if bearer not in user_ids:
raise DomainError("bearer_unknown")
bp = {u: (BP_TOTAL if u == bearer else 0) for u in user_ids}
return _ratio_rule(amount_ore, payer_user_id, user_ids, bp)
if mode == "ratio":
bp = spec["bp"]
# 4 bp_out_of_range, 5 bp_sum_not_10000
if any(not (0 <= v <= BP_TOTAL) for v in bp.values()):
raise DomainError("bp_out_of_range")
if sum(bp.values()) != BP_TOTAL:
raise DomainError("bp_sum_not_10000")
# 9 share_users_mismatch
if set(bp.keys()) != set(user_ids):
raise DomainError("share_users_mismatch")
return _ratio_rule(amount_ore, payer_user_id, user_ids, bp)
if mode == "exact":
ore = spec["ore"]
# 6 exact_negative, 7 exact_sum_mismatch
if any(v < 0 for v in ore.values()):
raise DomainError("exact_negative")
if sum(ore.values()) != amount_ore:
raise DomainError("exact_sum_mismatch")
if set(ore.keys()) != set(user_ids):
raise DomainError("share_users_mismatch")
return {u: ore[u] for u in user_ids}
raise ValueError("unknown split mode %r" % mode)
def _ratio_rule(amount_ore, payer, user_ids, bp):
non_payer = [u for u in user_ids if u != payer][0]
share_n = (amount_ore * bp[non_payer]) // BP_TOTAL # floor; all operands non-negative
return {non_payer: share_n, payer: amount_ore - share_n}
def compute_balances(user_ids, account_ids, expenses, settlements):
"""§9.3 and §14.5.2. Inputs hold only non-deleted rows."""
per = {}
def acc(a):
if a not in per:
per[a] = {u: 0 for u in user_ids}
return per[a]
for a in account_ids:
acc(a)
for e in expenses:
m = acc(e["account_id"])
m[e["payer_user_id"]] = m.get(e["payer_user_id"], 0) + e["amount_ore"]
for u, s in e["shares"].items():
m[u] = m.get(u, 0) - s
for s in settlements:
for a, amt in s["allocations"].items():
m = acc(a)
m[s["from_user_id"]] = m.get(s["from_user_id"], 0) + amt
m[s["to_user_id"]] = m.get(s["to_user_id"], 0) - amt
glob = {u: 0 for u in user_ids}
for m in per.values():
for u, v in m.items():
glob[u] = glob.get(u, 0) + v
return {"per_account": per, "global": glob}
def _account_order(a):
return (a["created_at"], a["account_id"])
def propose_allocation(amount_ore, accounts):
"""§9.4 algorithm, §14.5.2 semantics. Errors in AllocationError enum order."""
if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX):
raise DomainError("amount_out_of_range")
if not accounts:
raise DomainError("no_accounts")
owing = sorted([a for a in accounts if a["payer_net_ore"] < 0], key=_account_order)
allocations = []
remaining = amount_ore
for a in owing:
if remaining <= 0:
break
x = min(remaining, -a["payer_net_ore"])
allocations.append({"account_id": a["account_id"], "amount_ore": x})
remaining -= x
excess = 0
over = []
if owing:
if remaining > 0:
allocations[-1]["amount_ore"] += remaining
excess = remaining
over = [allocations[-1]["account_id"]]
else:
active = sorted([a for a in accounts if not a["archived"]], key=_account_order)
target = active[0] if active else sorted(accounts, key=_account_order)[0]
allocations = [{"account_id": target["account_id"], "amount_ore": amount_ore}]
excess = amount_ore
over = [target["account_id"]]
return {"allocations": allocations, "excess_ore": excess, "over_settled_account_ids": over}
def validate_settlement(amount_ore, from_user_id, to_user_id, user_ids, allocations):
"""§9.4 settlement validity. Errors in SettlementError enum order. Returns None when valid."""
if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX):
raise DomainError("amount_out_of_range")
if from_user_id == to_user_id:
raise DomainError("same_user")
if from_user_id not in user_ids or to_user_id not in user_ids:
raise DomainError("user_unknown")
if not allocations:
raise DomainError("no_allocations")
if len(allocations) > MAX_ALLOCATIONS:
raise DomainError("too_many_allocations")
if any(a["amount_ore"] < 1 for a in allocations):
raise DomainError("allocation_not_positive")
ids = [a["account_id"] for a in allocations]
if len(set(ids)) != len(ids):
raise DomainError("duplicate_account")
if sum(a["amount_ore"] for a in allocations) != amount_ore:
raise DomainError("allocation_sum_mismatch")
return None
def over_settled_accounts(allocations, accounts):
"""§9.4 over-settle warning, §14.5.2: unknown account counts as net 0."""
net = {a["account_id"]: a["payer_net_ore"] for a in accounts}
return [a["account_id"] for a in allocations if a["amount_ore"] > max(0, -net.get(a["account_id"], 0))]
def _d(s):
return datetime.date(int(s[0:4]), int(s[5:7]), int(s[8:10]))
def _month_key(d):
return "%04d-%02d" % (d.year, d.month)
def _week_key(d):
y, w, _ = d.isocalendar()
return "%04d-W%02d" % (y, w)
def _month_last(d):
nxt = datetime.date(d.year + (d.month // 12), d.month % 12 + 1, 1)
return nxt - datetime.timedelta(days=1)
def spend_series(expenses, range_, granularity, measure, group_by):
"""§14.5.4 spendSeries."""
start, end = _d(range_["start"]), _d(range_["end_inclusive"])
buckets = []
if granularity == "month":
cur = datetime.date(start.year, start.month, 1)
while cur <= end:
last = _month_last(cur)
buckets.append((_month_key(cur), max(cur, start), min(last, end)))
cur = last + datetime.timedelta(days=1)
keyf = _month_key
elif granularity == "week":
cur = start - datetime.timedelta(days=start.weekday())
while cur <= end:
last = cur + datetime.timedelta(days=6)
buckets.append((_week_key(cur), max(cur, start), min(last, end)))
cur = last + datetime.timedelta(days=1)
keyf = _week_key
else:
raise ValueError("granularity %r" % granularity)
totals = {k: {} for k, _, _ in buckets}
for e in expenses:
d = _d(e["date"])
if d < start or d > end:
continue
if measure == "household":
v = e["amount_ore"]
else:
v = e["shares"].get(measure["share"], 0)
g = e["category_id"] if group_by == "category" else e["account_id"]
t = totals[keyf(d)]
t[g] = t.get(g, 0) + v
out = []
group_totals = {}
for k, s, en in buckets:
t = {g: v for g, v in totals[k].items() if v != 0}
for g, v in t.items():
group_totals[g] = group_totals.get(g, 0) + v
out.append({"key": k, "start": s.isoformat(), "end_inclusive": en.isoformat(), "totals": t, "total": sum(t.values())})
group_totals = {g: v for g, v in group_totals.items() if v != 0}
return {"buckets": out, "group_totals": group_totals, "grand_total": sum(group_totals.values())}
def resolve_range(preset, today, earliest_expense, custom_start, custom_end):
"""§14.5.4 resolveRange."""
t = _d(today)
def months_back(n):
m = t.year * 12 + (t.month - 1) - (n - 1)
return datetime.date(m // 12, m % 12 + 1, 1)
if preset == "this_month":
s, e = datetime.date(t.year, t.month, 1), t
elif preset in ("last_3_months", "last_6_months", "last_12_months"):
s, e = months_back(int(preset.split("_")[1])), t
elif preset == "this_year":
s, e = datetime.date(t.year, 1, 1), t
elif preset == "all_time":
s, e = (_d(earliest_expense) if earliest_expense else t), t
elif preset == "custom":
s, e = _d(custom_start), _d(custom_end)
if s > e:
s, e = e, s
else:
raise ValueError("preset %r" % preset)
return {"start": s.isoformat(), "end_inclusive": e.isoformat()}
# Category suggestion (UX-3, category-suggestion.md CS-4 to CS-6; Kotlin only)
LETTER_CATEGORIES = ("Lu", "Ll", "Lt", "Lm", "Lo")
def normalize_description(s):
"""CS-4: NFC, lowercase without locale rules, delete ' ’ :, every other character that is not a letter, a decimal
digit or & becomes a space, collapse and trim. Works per code point."""
s = unicodedata.normalize("NFC", s).lower()
s = s.replace("'", "").replace("\u2019", "").replace(":", "")
kept = []
for c in s:
cat = unicodedata.category(c)
kept.append(c if (cat in LETTER_CATEGORIES or cat == "Nd" or c == "&") else " ")
return " ".join("".join(kept).split())
def _keyword_matches(entry, tokens):
kw, kind = entry["keyword"], entry["match"]
if kind == "token":
return kw in tokens
if kind == "prefix":
return any(t.startswith(kw) for t in tokens)
if kind == "phrase":
kt = kw.split(" ")
return any(tokens[i:i + len(kt)] == kt for i in range(len(tokens) - len(kt) + 1))
raise ValueError("match %r" % kind)
def _history_pick(rows, active, trace, stage):
"""CS-5 steps 1 and 2: most often among active categories, ties by the latest (expense_date, created_at) of each
tied category. A remaining tie (same count, same latest date and created_at) goes to the lower category id
(string order), as SPEC §9.11 CS-5 states."""
stats = {}
for r in rows:
cid = r["category_id"]
if cid not in active:
trace["skipped_inactive"] = True
continue
recency = (r["expense_date"], r["created_at"])
n, latest = stats.get(cid, (0, None))
stats[cid] = (n + 1, recency if latest is None or recency > latest else latest)
if not stats:
return None
ranked = sorted(stats.items(), key=lambda kv: kv[0])
ranked.sort(key=lambda kv: kv[1][1], reverse=True)
ranked.sort(key=lambda kv: kv[1][0], reverse=True)
if len(ranked) > 1 and ranked[0][1][0] == ranked[1][1][0]:
trace["count_tie"] = stage
return ranked[0][0]
def suggest_category_traced(description, history, keywords, active_categories, fallback):
trace = {}
active = set(c["id"] for c in active_categories)
by_key = dict((c["default_key"], c["id"]) for c in active_categories if c["default_key"] is not None)
norm = normalize_description(description)
if not norm:
trace["stage"] = "empty"
return fallback, trace
tokens = norm.split(" ")
normalised = [(normalize_description(r["description"]), r) for r in history]
got = _history_pick([r for n, r in normalised if n == norm], active, trace, "history_exact")
if got is not None:
trace["stage"] = "history_exact"
return got, trace
got = _history_pick([r for n, r in normalised if n and n.split(" ")[0] == tokens[0]], active, trace, "history_first_token")
if got is not None:
trace["stage"] = "history_first_token"
return got, trace
matching = [(i, e) for i, e in enumerate(keywords) if _keyword_matches(e, tokens)]
matching.sort(key=lambda ie: (-len(ie[1]["keyword"]), ie[0]))
if len(matching) > 1 and len(matching[0][1]["keyword"]) == len(matching[1][1]["keyword"]):
trace["equal_length_tie"] = True
for n, (_, e) in enumerate(matching):
cid = by_key.get(e["default_key"])
if cid is not None:
trace["stage"] = "keyword"
trace["match"] = e["match"]
if n > 0:
trace["keyword_fell_through"] = True
return cid, trace
if matching:
trace["keyword_fell_through"] = True
trace["stage"] = "fallback"
return fallback, trace
def suggest_category(description, history, keywords, active_categories, fallback):
"""CS-4 to CS-6 as the pure Kotlin function suggestCategory (UX-3). `history` holds the non-deleted expenses,
`active_categories` the categories that are neither archived nor deleted, `fallback` the AC-8.6 default."""
return suggest_category_traced(description, history, keywords, active_categories, fallback)[0]
# ----------------------------------------------------------------------------------------------------------------------
# Test vectors (§14.5.5)
# ----------------------------------------------------------------------------------------------------------------------
SPLIT_ERRORS = ["amount_out_of_range", "users_invalid", "payer_unknown", "bp_out_of_range", "bp_sum_not_10000",
"exact_negative", "exact_sum_mismatch", "bearer_unknown", "share_users_mismatch"]
ALLOCATION_ERRORS = ["amount_out_of_range", "no_accounts"]
SETTLEMENT_ERRORS = ["amount_out_of_range", "same_user", "user_unknown", "no_allocations", "too_many_allocations",
"allocation_not_positive", "duplicate_account", "allocation_sum_mismatch"]
PRESETS = ["this_month", "last_3_months", "last_6_months", "last_12_months", "this_year", "all_time", "custom"]
VECTOR_FUNCTIONS = {
# function: (input keys = Rust parameter names, or snake_case Kotlin names for the Kotlin-only functions,
# allowed error tokens, reference)
"compute_split": (["amount_ore", "payer_user_id", "user_ids", "spec"], SPLIT_ERRORS,
lambda i: compute_split(i["amount_ore"], i["payer_user_id"], i["user_ids"], i["spec"])),
"compute_balances": (["user_ids", "account_ids", "expenses", "settlements"], [],
lambda i: compute_balances(i["user_ids"], i["account_ids"], i["expenses"], i["settlements"])),
"propose_allocation": (["amount_ore", "accounts"], ALLOCATION_ERRORS,
lambda i: propose_allocation(i["amount_ore"], i["accounts"])),
"validate_settlement": (["amount_ore", "from_user_id", "to_user_id", "user_ids", "allocations"], SETTLEMENT_ERRORS,
lambda i: validate_settlement(i["amount_ore"], i["from_user_id"], i["to_user_id"],
i["user_ids"], i["allocations"])),
"over_settled_accounts": (["allocations", "accounts"], [],
lambda i: over_settled_accounts(i["allocations"], i["accounts"])),
"spend_series": (["expenses", "range", "granularity", "measure", "group_by"], [],
lambda i: spend_series(i["expenses"], i["range"], i["granularity"], i["measure"], i["group_by"])),
"resolve_range": (["preset", "today", "earliest_expense", "custom_start", "custom_end"], [],
lambda i: resolve_range(i["preset"], i["today"], i["earliest_expense"], i["custom_start"],
i["custom_end"])),
"suggest_category": (["description", "history", "keywords", "active_categories", "fallback"], [],
lambda i: suggest_category(i["description"], i["history"], i["keywords"],
i["active_categories"], i["fallback"])),
}
SUGGEST_MIN_CASES = 30
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
PERIOD_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
WEEK_RE = re.compile(r"^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$")
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
def is_int(v):
return isinstance(v, int) and not isinstance(v, bool)
def valid_date(s):
if not isinstance(s, str) or not DATE_RE.match(s):
return False
try:
_d(s)
return True
except ValueError:
return False
def valid_ts(s):
if not isinstance(s, str) or not TS_RE.match(s):
return False
try:
datetime.datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S")
return True
except ValueError:
return False
def check_vector_input(fn, inp):
"""Type check of a case input against the §14.5.1 types. Returns an error string or None."""
def int_map(m):
return isinstance(m, dict) and all(isinstance(k, str) and is_int(v) for k, v in m.items())
def str_list(x):
return isinstance(x, list) and all(isinstance(v, str) for v in x)
def position(a):
return (isinstance(a, dict) and set(a) == {"account_id", "created_at", "archived", "payer_net_ore"}
and isinstance(a["account_id"], str) and valid_ts(a["created_at"])
and isinstance(a["archived"], bool) and is_int(a["payer_net_ore"]))
def allocation(a):
return isinstance(a, dict) and set(a) == {"account_id", "amount_ore"} and isinstance(a["account_id"], str) and is_int(a["amount_ore"])
if fn == "compute_split":
sp = inp["spec"]
if not (is_int(inp["amount_ore"]) and isinstance(inp["payer_user_id"], str) and str_list(inp["user_ids"])):
return "scalar types"
if not isinstance(sp, dict) or sp.get("mode") not in ("ratio", "exact", "full"):
return "spec.mode must be ratio, exact or full"
want = {"ratio": {"mode", "bp"}, "exact": {"mode", "ore"}, "full": {"mode", "bearer_user_id"}}[sp["mode"]]
if set(sp) != want:
return "spec keys %s, expected %s" % (sorted(sp), sorted(want))
if sp["mode"] == "ratio" and not int_map(sp["bp"]):
return "spec.bp must map user id to int"
if sp["mode"] == "exact" and not int_map(sp["ore"]):
return "spec.ore must map user id to int"
if sp["mode"] == "full" and not isinstance(sp["bearer_user_id"], str):
return "spec.bearer_user_id must be a string"
elif fn == "compute_balances":
if not (str_list(inp["user_ids"]) and str_list(inp["account_ids"])):
return "id lists"
for e in inp["expenses"]:
if not (isinstance(e, dict) and set(e) == {"account_id", "amount_ore", "payer_user_id", "shares"}
and is_int(e["amount_ore"]) and int_map(e["shares"])):
return "expense %r" % (e,)
for s in inp["settlements"]:
if not (isinstance(s, dict) and set(s) == {"from_user_id", "to_user_id", "allocations"} and int_map(s["allocations"])):
return "settlement %r" % (s,)
elif fn == "propose_allocation":
if not is_int(inp["amount_ore"]) or not all(position(a) for a in inp["accounts"]):
return "amount or account positions"
elif fn == "validate_settlement":
if not (is_int(inp["amount_ore"]) and str_list(inp["user_ids"]) and all(allocation(a) for a in inp["allocations"])):
return "amount, user ids or allocations"
elif fn == "over_settled_accounts":
if not (all(allocation(a) for a in inp["allocations"]) and all(position(a) for a in inp["accounts"])):
return "allocations or account positions"
elif fn == "spend_series":
for e in inp["expenses"]:
if not (isinstance(e, dict) and set(e) == {"date", "account_id", "category_id", "amount_ore", "shares"}
and valid_date(e["date"]) and is_int(e["amount_ore"]) and int_map(e["shares"])):
return "expense %r" % (e,)
r = inp["range"]
if not (isinstance(r, dict) and set(r) == {"start", "end_inclusive"} and valid_date(r["start"]) and valid_date(r["end_inclusive"])):
return "range"
if inp["granularity"] not in ("month", "week") or inp["group_by"] not in ("category", "account"):
return "granularity or group_by"
m = inp["measure"]
if not (m == "household" or (isinstance(m, dict) and set(m) == {"share"} and isinstance(m["share"], str))):
return "measure must be \"household\" or {\"share\": id}"
elif fn == "resolve_range":
if inp["preset"] not in PRESETS or not valid_date(inp["today"]):
return "preset or today"
for k in ("earliest_expense", "custom_start", "custom_end"):
if inp[k] is not None and not valid_date(inp[k]):
return k
elif fn == "suggest_category":
if not isinstance(inp["description"], str):
return "description must be a string"
for h in inp["history"]:
if not (isinstance(h, dict) and set(h) == {"description", "category_id", "expense_date", "created_at"}
and isinstance(h["description"], str) and h["description"] and UUID_RE.match(str(h["category_id"]))
and valid_date(h["expense_date"]) and valid_ts(h["created_at"])):
return "history row %r" % (h,)
for k in inp["keywords"]:
if not (isinstance(k, dict) and set(k) == {"keyword", "match", "default_key"}
and isinstance(k["keyword"], str) and k["match"] in KEYWORD_MATCHES and k["default_key"] in DEFAULT_KEYS):
return "keyword %r" % (k,)
for c in inp["active_categories"]:
if not (isinstance(c, dict) and set(c) == {"id", "default_key"} and UUID_RE.match(str(c["id"]))
and (c["default_key"] is None or c["default_key"] in DEFAULT_KEYS)):
return "active category %r" % (c,)
ids = [c["id"] for c in inp["active_categories"]]
keys = [c["default_key"] for c in inp["active_categories"] if c["default_key"] is not None]
if len(set(ids)) != len(ids) or len(set(keys)) != len(keys):
return "active_categories: duplicate id or default_key"
if inp["fallback"] is not None and not UUID_RE.match(str(inp["fallback"])):
return "fallback must be a category id or null"
return None
def check_vector_ok_shape(fn, ok):
if fn == "compute_split":
return isinstance(ok, dict) and all(is_int(v) for v in ok.values())
if fn == "compute_balances":
return isinstance(ok, dict) and set(ok) == {"per_account", "global"}
if fn == "propose_allocation":
return (isinstance(ok, dict) and set(ok) == {"allocations", "excess_ore", "over_settled_account_ids"}
and all(isinstance(a, dict) and set(a) == {"account_id", "amount_ore"} for a in ok["allocations"]))
if fn == "validate_settlement":
return ok is None
if fn == "over_settled_accounts":
return isinstance(ok, list)
if fn == "spend_series":
return (isinstance(ok, dict) and set(ok) == {"buckets", "group_totals", "grand_total"}
and all(isinstance(b, dict) and set(b) == {"key", "start", "end_inclusive", "totals", "total"} for b in ok["buckets"]))
if fn == "resolve_range":
return isinstance(ok, dict) and set(ok) == {"start", "end_inclusive"}
if fn == "suggest_category":
return ok is None or (isinstance(ok, str) and bool(UUID_RE.match(ok)))
return False
def coverage(fn, cases):
"""Minimum cases of §14.5.5 that can be checked mechanically. Returns a list of missing items."""
missing = []
errors = set(c["expected"].get("error") for c in cases if "error" in c["expected"])
if fn == "compute_split":
missing += ["error " + e for e in SPLIT_ERRORS if e not in errors]
rows = [(1001, "a", 501, 500), (1, "a", 1, 0), (100, "b", 40, 60), (999, "a", 333, 666), (999, "b", 667, 332),
(50000, "a", 0, 50000), (50000, "a", 50000, 0)]
for amt, payer, sp, sn in rows:
hit = any(c["input"]["amount_ore"] == amt and c["input"]["payer_user_id"] == payer
and c["expected"].get("ok") is not None and c["expected"]["ok"].get(payer) == sp
and sorted(c["expected"]["ok"].values()) == sorted([sp, sn]) for c in cases)
if not hit:
missing.append("§9.1 row %d/%s" % (amt, payer))
modes = set((c["input"]["spec"]["mode"], "ok" if "ok" in c["expected"] else "err") for c in cases)
for m in [("full", "ok"), ("exact", "ok")]:
if m not in modes:
missing.append("%s %s" % m)
if not any(c["input"]["spec"]["mode"] == "full" and c["input"]["spec"]["bearer_user_id"] == c["input"]["payer_user_id"]
and "ok" in c["expected"] for c in cases):
missing.append("full on payer")
if not any(c["input"]["spec"]["mode"] == "full" and c["input"]["spec"]["bearer_user_id"] != c["input"]["payer_user_id"]
and "ok" in c["expected"] for c in cases):
missing.append("full on non-payer")
amounts = set(c["input"]["amount_ore"] for c in cases)
for a in (0, 1000000001, 1000000000):
if a not in amounts:
missing.append("amount %d" % a)
elif fn == "propose_allocation":
missing += ["error " + e for e in ALLOCATION_ERRORS if e not in errors]
oks = [c["expected"]["ok"] for c in cases if "ok" in c["expected"]]
if not any(o["excess_ore"] > 0 and len(o["allocations"]) > 1 for o in oks):
missing.append("excess added to the last owing account")
if not any(o["excess_ore"] == 0 and o["allocations"] for o in oks):
missing.append("no excess")
elif fn == "validate_settlement":
missing += ["error " + e for e in SETTLEMENT_ERRORS if e not in errors]
valid_sizes = set(len(c["input"]["allocations"]) for c in cases if "ok" in c["expected"])
for n in (3, 50):
if n not in valid_sizes:
missing.append("valid case with %d allocations" % n)
elif fn == "over_settled_accounts":
oks = [c["expected"]["ok"] for c in cases]
if [] not in oks:
missing.append("none")
if not any(len(o) == 1 for o in oks):
missing.append("one")
elif fn == "spend_series":
seen = set()
for c in cases:
i = c["input"]
seen.add(i["granularity"])
seen.add("household" if i["measure"] == "household" else "share")
seen.add(i["group_by"])
for b in c["expected"]["ok"]["buckets"]:
seen.add(b["key"])
if b["total"] == 0:
seen.add("empty bucket")
for want in ("month", "week", "household", "share", "category", "account", "2026-W53", "empty bucket"):
if want not in seen:
missing.append(want)
elif fn == "resolve_range":
presets = set(c["input"]["preset"] for c in cases)
missing += ["preset " + p for p in PRESETS if p not in presets]
if not any(c["input"]["preset"] == "all_time" and c["input"]["earliest_expense"] is None for c in cases):
missing.append("all_time without earliest expense")
if not any(c["input"]["preset"] == "all_time" and c["input"]["earliest_expense"] is not None for c in cases):
missing.append("all_time with earliest expense")
if not any(c["input"]["preset"] == "custom" and c["input"]["custom_start"] > c["input"]["custom_end"] for c in cases):
missing.append("custom swapped")
if not all(any(c["input"]["preset"] == p and c["input"]["today"] == "2026-10-17" for c in cases) for p in PRESETS if p != "custom"):
missing.append("every preset with today 2026-10-17")
elif fn == "suggest_category":
if len(cases) < SUGGEST_MIN_CASES:
missing.append("at least %d cases (has %d)" % (SUGGEST_MIN_CASES, len(cases)))
seen = set()
for c in cases:
i = c["input"]
_, t = suggest_category_traced(i["description"], i["history"], i["keywords"], i["active_categories"], i["fallback"])
seen.add(t["stage"])
if i["description"] == "":
seen.add("empty description")
if t["stage"] == "keyword":
seen.add("keyword " + t["match"])
for k in ("skipped_inactive", "equal_length_tie", "keyword_fell_through"):
if t.get(k):
seen.add(k)
if t.get("count_tie"):
seen.add("tie " + t["count_tie"])
if c["expected"]["ok"] is None:
seen.add("null result")
if normalize_description(i["description"]) != " ".join(i["description"].split()).lower():
seen.add("normalisation changes the description")
for want in ("empty description", "empty", "history_exact", "history_first_token", "keyword", "fallback",
"keyword token", "keyword prefix", "keyword phrase", "skipped_inactive", "equal_length_tie",
"keyword_fell_through", "tie history_exact", "null result", "normalisation changes the description"):
if want not in seen:
missing.append(want)
return missing
def check_vectors():
d = os.path.join(CONTRACTS, "test-vectors")
expected_files = set(f + ".json" for f in VECTOR_FUNCTIONS)
present = set(f for f in os.listdir(d) if f.endswith(".json")) if os.path.isdir(d) else set()
report(present == expected_files, "vectors: file set is exactly §14.5.5",
"missing %s, extra %s" % (sorted(expected_files - present), sorted(present - expected_files)) if present != expected_files else "%d files" % len(present))
counts = {}
for fn in sorted(VECTOR_FUNCTIONS):
path = os.path.join(d, fn + ".json")
if not os.path.exists(path):
continue
try:
with open(path, encoding="utf-8") as f:
doc = json.load(f)
except ValueError as e:
report(False, "vectors %s format" % fn, "does not parse: %s" % e)
continue
keys, errs, ref = VECTOR_FUNCTIONS[fn]
problems = []
if not isinstance(doc, dict) or set(doc) != {"format", "function", "cases"}:
problems.append("top-level keys must be format, function, cases")
else:
if doc["format"] != 1:
problems.append("format must be 1")
if doc["function"] != fn:
problems.append("function %r does not match file name" % doc["function"])
if not isinstance(doc["cases"], list) or not doc["cases"]:
problems.append("cases must be a non-empty list")
cases = doc.get("cases") if isinstance(doc, dict) and isinstance(doc.get("cases"), list) else []
names = set()
for n, c in enumerate(cases):
where = "case %d" % n
if not isinstance(c, dict) or set(c) != {"name", "input", "expected"}:
problems.append("%s: keys must be name, input, expected" % where)
continue
if not isinstance(c["name"], str) or not c["name"]:
problems.append("%s: name must be a non-empty string" % where)
elif c["name"] in names:
problems.append("%s: duplicate name %r" % (where, c["name"]))
names.add(c["name"])
if not isinstance(c["input"], dict) or set(c["input"]) != set(keys):
problems.append("%s (%s): input keys %s, expected %s" % (where, c["name"], sorted(c["input"]) if isinstance(c["input"], dict) else "?", sorted(keys)))
continue
bad = check_vector_input(fn, c["input"])
if bad:
problems.append("%s (%s): input %s" % (where, c["name"], bad))
exp = c["expected"]
if not isinstance(exp, dict) or len(exp) != 1 or list(exp)[0] not in ("ok", "error"):
problems.append("%s (%s): expected must be {\"ok\": …} or {\"error\": …}" % (where, c["name"]))
continue
if "error" in exp and exp["error"] not in errs:
problems.append("%s (%s): error token %r not allowed for %s" % (where, c["name"], exp["error"], fn))
if "ok" in exp and not check_vector_ok_shape(fn, exp["ok"]):
problems.append("%s (%s): ok value has the wrong shape" % (where, c["name"]))
report(not problems, "vectors %s.json format (§14.5.5)" % fn, "; ".join(problems) if problems else "%d cases" % len(cases))
if problems:
continue
missing = coverage(fn, cases)
report(not missing, "vectors %s.json minimum cases (§14.5.5)" % fn, "missing: " + ", ".join(missing) if missing else "covered")
mismatches = []
for c in cases:
try:
got = {"ok": ref(c["input"])}
except DomainError as e:
got = {"error": e.token}
if got != c["expected"]:
mismatches.append("%r: expected %s, reference gives %s" % (c["name"], json.dumps(c["expected"], ensure_ascii=False), json.dumps(got, ensure_ascii=False)))
report(not mismatches, "vectors %s.json reproduced by the reference implementation" % fn,
" | ".join(mismatches) if mismatches else "%d/%d cases" % (len(cases), len(cases)))
counts[fn] = len(cases)
return counts
# ----------------------------------------------------------------------------------------------------------------------
# Wire shapes (§14.3.3, §14.3.4, §14.4.2, §14.4.3, §14.6)
# ----------------------------------------------------------------------------------------------------------------------
# A type is: "uuid" | "string" | "int" | "bool" | "ts" | "date" | "period" | "any" | "hex32"
# | ("enum", [values]) | ("null", type) | ("array", type, min, max) | "<ShapeName>"
# A shape is {"fields": {key: type}, "optional": set of keys that may be absent, "patch": bool (all optional, ≥ 1)}
def N(t):
return ("null", t)
def A(t, lo=0, hi=None):
return ("array", t, lo, hi)
def E(*vals):
return ("enum", list(vals))
ROLE = E("admin", "member")
INVITE_KIND = E("admin_setup", "partner", "device")
DEFAULT_KEYS = ["groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health",
"shopping", "other", "housing", "insurance", "children", "fees"]
DEFAULT_KEY = E(*DEFAULT_KEYS)
KEYWORD_MATCHES = ["token", "prefix", "phrase"]
SPLIT_MODE = E("default", "ratio", "exact", "full")
ENTITY_TYPE = E("expense", "settlement")
REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew")
ERROR_CODES = ["invalid_request", "invite_invalid", "unauthorized", "device_revoked", "forbidden", "not_found",
"household_full", "household_incomplete", "name_taken", "account_not_empty", "category_in_use",
"category_is_default", "cannot_revoke_current_device", "no_accounts", "cursor_ahead", "payload_too_large",
"unsupported_media_type", "validation_failed", "image_invalid", "batch_too_large", "protocol_unsupported",
"rate_limited", "internal", "not_implemented", "unavailable"]
PUSH_TYPES = ["expense_created", "expense_updated", "expense_deleted", "settlement_created", "settlement_updated",
"settlement_deleted", "recurring_expense_created", "sync_batch"]
ICONS = ["shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train",
"local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center",
"medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard",
"celebration", "build", "chair", "subscriptions", "category", "key", "shield", "account_balance"]
COLORS = ["green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime",
"slate", "amber"]
def S(fields, optional=(), patch=False):
return {"fields": fields, "optional": set(optional), "patch": patch}
TEMPLATE_WRITABLE = {
"account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string", "notes": "string",
"payer_user_id": "uuid", "split_mode": SPLIT_MODE, "splits": A("TemplateSplit", 0, 2), "day_of_month": "int",
"start_period": "period", "end_period": N("period"), "paused": "bool",
}
SHAPES = {
# §14.3.3 wire objects
"User": S({"id": "uuid", "role": ROLE, "display_name": "string", "picture_version": N("int"), "created_at": "ts",
"updated_at": "ts", "server_version": "int"}),
"Device": S({"id": "uuid", "user_id": "uuid", "name": "string", "app_version_code": N("int"),
"app_version_name": N("string"), "created_at": "ts", "last_seen_at": "ts", "revoked_at": N("ts"),
"is_current": "bool"}),
"Invite": S({"id": "uuid", "kind": INVITE_KIND, "target_user_id": N("uuid"), "code": N("string"), "link": N("string"),
"created_at": "ts", "expires_at": "ts"}),
"Share": S({"user_id": "uuid", "share_bp": "int"}),
"Account": S({"id": "uuid", "name": "string", "default_shares": A("Share", 2, 2), "created_by_user_id": "uuid",
"created_at": "ts", "updated_at": "ts", "archived_at": N("ts"), "deleted_at": N("ts"), "server_version": "int"}),
"Category": S({"id": "uuid", "default_key": N(DEFAULT_KEY), "name": N("string"), "icon": "string", "color": "string",
"sort_order": "int", "created_at": "ts", "updated_at": "ts", "archived_at": N("ts"), "deleted_at": N("ts"),
"server_version": "int"}),
"Split": S({"user_id": "uuid", "share_ore": "int", "share_bp": N("int")}),
"TemplateSplit": S({"user_id": "uuid", "share_bp": N("int"), "share_ore": N("int")}),
"Expense": S({"id": "uuid", "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string",
"notes": "string", "expense_date": "date", "payer_user_id": "uuid", "split_mode": SPLIT_MODE,
"splits": A("Split", 2, 2), "recurring_template_id": N("uuid"), "recurring_period": N("period"),
"created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts",
"deleted_at": N("ts"), "server_version": "int"}),
"Allocation": S({"account_id": "uuid", "amount_ore": "int"}),
"Settlement": S({"id": "uuid", "from_user_id": "uuid", "to_user_id": "uuid", "amount_ore": "int",
"settlement_date": "date", "note": "string", "allocations": A("Allocation", 1, 50),
"created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts",
"deleted_at": N("ts"), "server_version": "int"}),
"RecurringTemplate": S(dict(TEMPLATE_WRITABLE, **{
"id": "uuid", "last_generated_period": N("period"), "next_occurrence_date": N("date"),
"created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts",
"deleted_at": N("ts"), "server_version": "int"})),
"AuditChange": S({"field": "string", "old": "any", "new": "any"}),
"AuditEntry": S({"id": "uuid", "entity_type": ENTITY_TYPE, "entity_id": "uuid", "action": E("create", "update", "delete"),
"source": E("app", "recurring", "import"), "actor_user_id": "uuid", "client_at": "ts", "server_at": "ts",
"changes": A("AuditChange"), "server_version": "int"}),
"Error": S({"code": E(*ERROR_CODES), "message": "string", "field": N("string")}),
# §14.3.4 request and response bodies
"Health": S({"status": E("ok", "degraded"), "server_version": "string", "schema_version": "int", "db_ok": "bool"}),
"SetupStatus": S({"initialized": "bool"}),
"AcceptInviteRequest": S({"code": "string", "device_name": "string", "display_name": "string"}, optional=["display_name"]),
"AcceptInviteResponse": S({"token": "string", "invite_kind": INVITE_KIND, "user": "User", "device": "Device", "household": A("User", 1, 2)}),
"ApkLatest": S({"version_code": "int", "version_name": "string", "file_name": "string", "size_bytes": "int",
"sha256": "string", "released_at": "ts"}),
"MeResponse": S({"user": "User", "device": "Device", "household": A("User", 1, 2), "public_url": "string"}),
"PatchMeRequest": S({"display_name": "string"}, patch=True),
"CreateInviteRequest": S({"kind": E("partner", "device"), "target_user_id": N("uuid")}, optional=["target_user_id"]),
"InviteList": S({"invites": A("Invite")}),
"DeviceList": S({"devices": A("Device")}),
"PushTokenRequest": S({"fcm_token": N("string")}),
"CreateAccountRequest": S({"name": "string", "default_shares": A("Share", 2, 2)}),
"PatchAccountRequest": S({"name": "string", "default_shares": A("Share", 2, 2)}, patch=True),
"CreateCategoryRequest": S({"name": "string", "icon": "string", "color": "string"}),
"PatchCategoryRequest": S({"name": N("string"), "icon": "string", "color": "string", "sort_order": "int"}, patch=True),
"CreateRecurringTemplateRequest": S(dict(TEMPLATE_WRITABLE)),
"PatchRecurringTemplateRequest": S(dict(TEMPLATE_WRITABLE), patch=True),
"ProposalRequest": S({"payer_user_id": "uuid", "amount_ore": "int", "exclude_settlement_id": N("uuid")},
optional=["exclude_settlement_id"]),
"ProposalResponse": S({"allocations": A("Allocation"), "excess_ore": "int", "over_settled_account_ids": A("uuid")}),
# §14.4.2 and §14.4.3 sync
"SyncPullResponse": S({"epoch": "hex32", "since": "int", "next_since": "int", "has_more": "bool", "server_time": "ts",
"users": A("User"), "categories": A("Category"), "accounts": A("Account"),
"recurring_templates": A("RecurringTemplate"), "expenses": A("Expense"),
"settlements": A("Settlement"), "audit_entries": A("AuditEntry")}),
"PushedExpense": S({"id": "uuid", "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string",
"notes": "string", "expense_date": "date", "payer_user_id": "uuid", "split_mode": SPLIT_MODE,
"splits": A("Split", 2, 2), "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts")}),
"PushedSettlement": S({"id": "uuid", "from_user_id": "uuid", "to_user_id": "uuid", "amount_ore": "int",
"settlement_date": "date", "note": "string", "allocations": A("Allocation", 1, 50),
"created_at": "ts", "updated_at": "ts", "deleted_at": N("ts")}),
"ExpenseMutation": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity": "PushedExpense"}),
"SettlementMutation": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity": "PushedSettlement"}),
"SyncPushRequest": S({"mutations": A("Mutation", 1, 100)}),
"AcceptedExpense": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity_id": "uuid", "status": E("accepted"),
"server_version": "int", "entity": "Expense"}),
"AcceptedSettlement": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity_id": "uuid", "status": E("accepted"),
"server_version": "int", "entity": "Settlement"}),
"RejectedExpense": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity_id": "uuid", "status": E("rejected"),
"reason": REASON, "message": "string", "current": N("Expense")}),
"RejectedSettlement": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity_id": "uuid", "status": E("rejected"),
"reason": REASON, "message": "string", "current": N("Settlement")}),
"SyncPushResponse": S({"server_time": "ts", "results": A("MutationResult", 1, 100)}),
# §14.6 push payload
"FcmMessage": S({"message": "FcmBody"}),
"FcmBody": S({"fid": "string", "android": "FcmAndroid", "data": "FcmData"}),
"FcmAndroid": S({"priority": E("high"), "ttl": E("86400s")}),
"FcmData": S({"v": E("1"), "type": E(*PUSH_TYPES), "entity_type": E("expense", "settlement", ""), "entity_id": "string",
"actor_user_id": "uuid", "count": "string"}),
}
UNIONS = {
"Mutation": lambda o: {"expense": "ExpenseMutation", "settlement": "SettlementMutation"}.get(o.get("entity_type")),
"MutationResult": lambda o: {("expense", "accepted"): "AcceptedExpense", ("settlement", "accepted"): "AcceptedSettlement",
("expense", "rejected"): "RejectedExpense", ("settlement", "rejected"): "RejectedSettlement"
}.get((o.get("entity_type"), o.get("status"))),
}
def validate(value, typ, path, errs, seen):
"""Validate value against typ; append messages to errs; record (shape, object) pairs in seen."""
if isinstance(typ, tuple):
kind = typ[0]
if kind == "null":
if value is not None:
validate(value, typ[1], path, errs, seen)
return
if kind == "enum":
if value not in typ[1]:
errs.append("%s: %r is not one of %s" % (path, value, typ[1]))
return
if kind == "array":
if not isinstance(value, list):
errs.append("%s: expected an array" % path)
return
lo, hi = typ[2], typ[3]
if len(value) < lo or (hi is not None and len(value) > hi):
errs.append("%s: array length %d outside %s..%s" % (path, len(value), lo, hi))
for i, v in enumerate(value):
validate(v, typ[1], "%s[%d]" % (path, i), errs, seen)
return
if typ in UNIONS:
if not isinstance(value, dict):
errs.append("%s: expected an object" % path)
return
member = UNIONS[typ](value)
if member is None:
errs.append("%s: no %s variant for this object" % (path, typ))
return
validate(value, member, path, errs, seen)
return
if typ in SHAPES:
shape = SHAPES[typ]
if not isinstance(value, dict):
errs.append("%s: expected a %s object" % (path, typ))
return
keys, fields = set(value), set(shape["fields"])
if shape["patch"]:
if not keys or not keys <= fields:
errs.append("%s: %s keys %s must be a non-empty subset of %s" % (path, typ, sorted(keys), sorted(fields)))
else:
required = fields - shape["optional"]
if not (required <= keys <= fields):
errs.append("%s: %s keys differ: missing %s, unknown %s" % (path, typ, sorted(required - keys), sorted(keys - fields)))
for k in keys & fields:
validate(value[k], shape["fields"][k], "%s.%s" % (path, k), errs, seen)
seen.append((typ, value, path))
return
ok = {
"uuid": lambda v: isinstance(v, str) and bool(UUID_RE.match(v)),
"string": lambda v: isinstance(v, str),
"int": is_int,
"bool": lambda v: isinstance(v, bool),
"ts": valid_ts,
"date": valid_date,
"period": lambda v: isinstance(v, str) and bool(PERIOD_RE.match(v)),
"hex32": lambda v: isinstance(v, str) and bool(re.match(r"^[0-9a-f]{32}$", v)),
"any": lambda v: True,
}.get(typ)
if ok is None:
errs.append("%s: unknown type %r in the checker" % (path, typ))
elif not ok(value):
errs.append("%s: %r is not a valid %s" % (path, value, typ))
def semantic(shape, o, path, errs):
"""Invariants of §9.1, §9.4, §9.6, §9.7, §14.3.3, §14.6 on objects that passed the shape check."""
def sorted_by(lst, key):
ids = [x[key] for x in lst]
return ids == sorted(ids)
if shape in ("Expense", "PushedExpense"):
sp = o["splits"]
if not sorted_by(sp, "user_id"):
errs.append("%s.splits: not sorted by user_id" % path)
if sum(s["share_ore"] for s in sp) != o["amount_ore"] and shape == "Expense":
errs.append("%s.splits: shares do not sum to amount_ore" % path)
if shape == "Expense" and len(sp) == 2:
users = [s["user_id"] for s in sp]
try:
if o["split_mode"] == "exact":
if any(s["share_bp"] is not None for s in sp):
errs.append("%s.splits: share_bp must be null for mode exact" % path)
spec = {"mode": "exact", "ore": {s["user_id"]: s["share_ore"] for s in sp}}
else:
spec = {"mode": "ratio", "bp": {s["user_id"]: s["share_bp"] for s in sp}}
if any(s["share_bp"] is None for s in sp):
raise DomainError("share_bp null in mode %s" % o["split_mode"])
if o["split_mode"] == "full" and sorted(s["share_bp"] for s in sp) != [0, 10000]:
errs.append("%s.splits: mode full needs bp 10000 and 0" % path)
want = compute_split(o["amount_ore"], o["payer_user_id"], users, spec)
got = {s["user_id"]: s["share_ore"] for s in sp}
if want != got:
errs.append("%s.splits: share_ore %s, §9.1 gives %s" % (path, got, want))
except DomainError as e:
errs.append("%s.splits: %s" % (path, e.token))
if shape == "Expense" and (o["recurring_template_id"] is None) != (o["recurring_period"] is None):
errs.append("%s: recurring_template_id and recurring_period must be both null or both set" % path)
if shape in ("Settlement", "PushedSettlement"):
al = o["allocations"]
if not sorted_by(al, "account_id"):
errs.append("%s.allocations: not sorted by account_id" % path)
try:
validate_settlement(o["amount_ore"], o["from_user_id"], o["to_user_id"], [o["from_user_id"], o["to_user_id"]], al)
except DomainError as e:
errs.append("%s: %s" % (path, e.token))
if shape in ("Account", "CreateAccountRequest", "PatchAccountRequest") and "default_shares" in o:
ds = o["default_shares"]
if not sorted_by(ds, "user_id"):
errs.append("%s.default_shares: not sorted by user_id" % path)
if sum(s["share_bp"] for s in ds) != 10000 or any(not 0 <= s["share_bp"] <= 10000 for s in ds):
errs.append("%s.default_shares: bp must be 0..10000 and sum to 10000" % path)
if shape in ("Category", "CreateCategoryRequest", "PatchCategoryRequest"):
if "icon" in o and o["icon"] not in ICONS:
errs.append("%s.icon: %r is not a §9.7 icon token" % (path, o["icon"]))
if "color" in o and o["color"] not in COLORS:
errs.append("%s.color: %r is not a §9.7 colour token" % (path, o["color"]))
if shape == "Category" and o["name"] is None and o["default_key"] is None:
errs.append("%s: name and default_key cannot both be null" % path)
if shape in ("RecurringTemplate", "CreateRecurringTemplateRequest", "PatchRecurringTemplateRequest") and "splits" in o:
mode, sp = o.get("split_mode"), o["splits"]
if not sorted_by(sp, "user_id"):
errs.append("%s.splits: not sorted by user_id" % path)
for s in sp:
if (s["share_bp"] is None) == (s["share_ore"] is None):
errs.append("%s.splits: exactly one of share_bp / share_ore must be set" % path)
if mode == "default" and sp:
errs.append("%s.splits: must be empty for mode default" % path)
if mode in ("ratio", "full") and (len(sp) != 2 or any(s["share_bp"] is None for s in sp) or sum(s["share_bp"] or 0 for s in sp) != 10000):
errs.append("%s.splits: two bp entries summing to 10000 required for mode %s" % (path, mode))
if mode == "exact" and (len(sp) != 2 or any(s["share_ore"] is None for s in sp) or sum(s["share_ore"] or 0 for s in sp) != o.get("amount_ore")):
errs.append("%s.splits: two öre entries summing to the amount required for mode exact" % path)
if shape in ("AcceptedExpense", "AcceptedSettlement"):
if o["entity"]["id"] != o["entity_id"] or o["entity"]["server_version"] != o["server_version"]:
errs.append("%s: entity id / server_version do not match the result" % path)
if shape in ("RejectedExpense", "RejectedSettlement") and o["current"] is not None and o["current"]["id"] != o["entity_id"]:
errs.append("%s: current.id does not match entity_id" % path)
if shape == "SyncPullResponse":
for k in ("users", "categories", "accounts", "recurring_templates", "expenses", "settlements", "audit_entries"):
if not sorted_by(o[k], "server_version"):
errs.append("%s.%s: not sorted by server_version" % (path, k))
versions = [r["server_version"] for k in ("users", "categories", "accounts", "recurring_templates", "expenses",
"settlements", "audit_entries") for r in o[k]]
if versions and max(versions) != o["next_since"]:
errs.append("%s: next_since must be the highest server_version in the page" % path)
if not versions and o["next_since"] != o["since"]:
errs.append("%s: next_since must equal since for an empty page" % path)
if any(v <= o["since"] for v in versions):
errs.append("%s: rows must have server_version > since" % path)
if shape == "AcceptInviteResponse" and not re.match(r"^kvt_[A-Za-z0-9_-]{43}$", o["token"]):
errs.append("%s.token: must be kvt_ + 43 base64url characters (§8.2)" % path)
if shape == "Invite" and o["code"] is not None and not re.match(r"^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{12}$", o["code"]):
errs.append("%s.code: must be 12 Crockford base32 characters (§8.3)" % path)
if shape == "FcmData":
if o["type"] == "sync_batch":
if o["entity_type"] != "" or o["entity_id"] != "" or not re.match(r"^[1-9][0-9]*$", o["count"]):
errs.append("%s: sync_batch needs empty entity_type and entity_id and a positive count" % path)
else:
want = "settlement" if o["type"].startswith("settlement") else "expense"
if o["entity_type"] != want or not UUID_RE.match(o["entity_id"]) or o["count"] != "1":
errs.append("%s: %s needs entity_type %s, a uuid entity_id and count \"1\"" % (path, o["type"], want))
EXAMPLES = {
"E-1-health/response.json": "Health",
"E-2-setup-status/response.json": "SetupStatus",
"E-3-invites-accept/request.json": "AcceptInviteRequest",
"E-3-invites-accept/response.json": "AcceptInviteResponse",
"E-5-apk-latest/response.json": "ApkLatest",
"E-7-me/response.json": "MeResponse",
"E-8-patch-me/request.json": "PatchMeRequest",
"E-8-patch-me/response.json": "User",
"E-9-put-me-picture/response.json": "User",
"E-10-delete-me-picture/response.json": "User",
"E-12-create-invite/request.json": "CreateInviteRequest",
"E-12-create-invite/response.json": "Invite",
"E-13-list-invites/response.json": "InviteList",
"E-15-list-devices/response.json": "DeviceList",
"E-17-push-token/request.json": "PushTokenRequest",
"E-18-create-account/request.json": "CreateAccountRequest",
"E-18-create-account/response.json": "Account",
"E-19-patch-account/request.json": "PatchAccountRequest",
"E-19-patch-account/response.json": "Account",
"E-20-archive-account/response.json": "Account",
"E-21-unarchive-account/response.json": "Account",
"E-22-delete-account/response.json": "Account",
"E-23-create-category/request.json": "CreateCategoryRequest",
"E-23-create-category/response.json": "Category",
"E-24-patch-category/request.json": "PatchCategoryRequest",
"E-24-patch-category/response.json": "Category",
"E-25-archive-category/response.json": "Category",
"E-26-unarchive-category/response.json": "Category",
"E-26-unarchive-category/response-default.json": "Category",
"E-27-delete-category/response.json": "Category",
"E-28-create-recurring-template/request.json": "CreateRecurringTemplateRequest",
"E-28-create-recurring-template/response.json": "RecurringTemplate",
"E-29-patch-recurring-template/request.json": "PatchRecurringTemplateRequest",
"E-29-patch-recurring-template/response.json": "RecurringTemplate",
"E-30-delete-recurring-template/response.json": "RecurringTemplate",
"E-31-sync-pull/response.json": "SyncPullResponse",
"E-32-sync-push/request.json": "SyncPushRequest",
"E-32-sync-push/response.json": "SyncPushResponse",
"E-33-settlement-proposal/request.json": "ProposalRequest",
"E-33-settlement-proposal/response.json": "ProposalResponse",
"sync/pull-page.json": "SyncPullResponse",
"sync/pull-empty-page.json": "SyncPullResponse",
"sync/pull-page-categories-v2.json": "SyncPullResponse",
"sync/push-request.json": "SyncPushRequest",
"sync/push-response.json": "SyncPushResponse",
"sync/push-request-delete.json": "SyncPushRequest",
"sync/push-response-delete.json": "SyncPushResponse",
"sync/push-request-rejections.json": "SyncPushRequest",
"sync/push-response-rejections.json": "SyncPushResponse",
}
EXAMPLES.update({"errors/%s.json" % c: "Error" for c in ERROR_CODES})
EXAMPLES.update({"push/%s.json" % t: "FcmMessage" for t in PUSH_TYPES})
def check_examples():
d = os.path.join(CONTRACTS, "examples")
present = set()
for dirpath, _, files in os.walk(d):
for f in files:
present.add(os.path.relpath(os.path.join(dirpath, f), d).replace(os.sep, "/"))
missing, extra = sorted(set(EXAMPLES) - present), sorted(present - set(EXAMPLES))
report(not missing and not extra, "examples: file set (one per JSON body of §14.3.4, error code, sync and push payload)",
"missing %s, unmapped %s" % (missing, extra) if (missing or extra) else "%d files" % len(present))
seen_reasons, seen_status = set(), set()
def order(s):
m = re.match(r"^E-(\d+)-", s)
return (0, int(m.group(1)), s) if m else (1, 0, s)
for name in sorted(set(EXAMPLES) & present, key=order):
shape = EXAMPLES[name]
try:
with open(os.path.join(d, name), encoding="utf-8") as f:
doc = json.load(f)
except ValueError as e:
report(False, "example %s" % name, "does not parse: %s" % e)
continue
errs, seen = [], []
validate(doc, shape, "$", errs, seen)
if not errs:
for sh, o, p in seen:
semantic(sh, o, p, errs)
if sh.startswith("Rejected"):
seen_reasons.add(o["reason"])
if sh.startswith("Accepted") or sh.startswith("Rejected"):
seen_status.add(o["status"])
if name.startswith("errors/") and not errs and doc["code"] != name[len("errors/"):-len(".json")]:
errs.append("code does not match the file name")
if name.startswith("push/") and not errs and doc["message"]["data"]["type"] != name[len("push/"):-len(".json")]:
errs.append("type does not match the file name")
report(not errs, "example %s matches %s" % (name, shape), "; ".join(errs[:5]))
reasons = set(REASON[1])
report(seen_reasons == reasons and seen_status == {"accepted", "rejected"},
"examples: every push result status and rejection reason of §14.4.6 appears",
"missing %s" % sorted((reasons - seen_reasons) | ({"accepted", "rejected"} - seen_status)) if seen_reasons != reasons else "")
# ----------------------------------------------------------------------------------------------------------------------
# SQLite schema (§14.1)
# ----------------------------------------------------------------------------------------------------------------------
def check_schema():
path = os.path.join(CONTRACTS, "schema", "server-v1.sql")
if not os.path.exists(path):
report(False, "schema server-v1.sql exists", "missing")
return
with open(path, encoding="utf-8") as f:
sql = f.read()
with open(os.path.join(ROOT, "SPEC.md"), encoding="utf-8") as f:
spec = f.read()
try:
i = spec.index("### 14.1 SQLite schema v1")
j = spec.index("```sql\n", i) + len("```sql\n")
block = spec[j:spec.index("```\n", j)]
report(block == sql, "schema server-v1.sql is byte-identical to the SPEC §14.1 DDL",
"differs from SPEC §14.1" if block != sql else "%d bytes" % len(sql.encode("utf-8")))
except ValueError:
report(False, "schema server-v1.sql is byte-identical to the SPEC §14.1 DDL", "§14.1 sql block not found in SPEC.md")
if sqlite3.sqlite_version_info < (3, 37, 0):
report(False, "schema server-v1.sql loads in SQLite", "SQLite %s is older than 3.37 (STRICT tables)" % sqlite3.sqlite_version)
return
db = sqlite3.connect(":memory:")
try:
db.execute("PRAGMA foreign_keys = ON")
db.executescript(sql)
report(True, "schema server-v1.sql loads in SQLite", "SQLite %s" % sqlite3.sqlite_version)
except sqlite3.Error as e:
report(False, "schema server-v1.sql loads in SQLite", str(e))
return
fk = db.execute("PRAGMA foreign_key_check").fetchall()
report(not fk, "schema PRAGMA foreign_key_check is clean", "%r" % fk if fk else "")
tables = set(r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'"))
want = {"meta", "sync_sequence", "users", "devices", "invites", "accounts", "account_default_shares", "categories",
"recurring_templates", "recurring_template_splits", "expenses", "expense_splits", "settlements",
"settlement_allocations", "audit_log", "sync_mutations"}
report(tables == want, "schema tables are the 16 of §14.1", "got %s" % sorted(tables) if tables != want else "")
meta = dict(db.execute("SELECT key, value FROM meta"))
seq = db.execute("SELECT id, value FROM sync_sequence").fetchall()
cats = db.execute("SELECT default_key, icon, color, sort_order, server_version FROM categories ORDER BY sort_order").fetchall()
want_cats = [("groceries", "shopping_cart", "green", 10, 1), ("restaurants", "restaurant", "orange", 20, 2),
("home", "home", "brown", 30, 3), ("utilities", "bolt", "yellow", 40, 4),
("transport", "directions_car", "blue", 50, 5), ("travel", "flight", "teal", 60, 6),
("entertainment", "movie", "purple", 70, 7), ("health", "medical_services", "red", 80, 8),
("shopping", "shopping_bag", "pink", 90, 9), ("other", "category", "grey", 100, 10)]
ok = (meta.get("schema_version") == "1" and re.match(r"^[0-9a-f]{32}$", meta.get("sync_epoch", "")) is not None
and seq == [(1, 10)] and cats == want_cats)
report(ok, "schema seed rows (schema_version 1, sync_epoch, sync_sequence 10, ten default categories of AC-7.2)",
"meta %r, sequence %r, categories %r" % (meta, seq, cats) if not ok else "")
db.close()
V1_DEFAULT_CATEGORIES = [
("01920000-0000-7000-8000-000000000001", "groceries", "shopping_cart", "green", 10),
("01920000-0000-7000-8000-000000000002", "restaurants", "restaurant", "orange", 20),
("01920000-0000-7000-8000-000000000003", "home", "home", "brown", 30),
("01920000-0000-7000-8000-000000000004", "utilities", "bolt", "yellow", 40),
("01920000-0000-7000-8000-000000000005", "transport", "directions_car", "blue", 50),
("01920000-0000-7000-8000-000000000006", "travel", "flight", "teal", 60),
("01920000-0000-7000-8000-000000000007", "entertainment", "movie", "purple", 70),
("01920000-0000-7000-8000-000000000008", "health", "medical_services", "red", 80),
("01920000-0000-7000-8000-000000000009", "shopping", "shopping_bag", "pink", 90),
("01920000-0000-7000-8000-00000000000a", "other", "category", "grey", 100),
]
V2_NEW_CATEGORIES = [
("01920000-0000-7000-8000-000000000011", "housing", "key", "indigo", 25),
("01920000-0000-7000-8000-000000000012", "insurance", "shield", "slate", 45),
("01920000-0000-7000-8000-000000000013", "children", "child_care", "lime", 85),
("01920000-0000-7000-8000-000000000014", "fees", "account_balance", "amber", 95),
]
MIGRATION_0002 = os.path.join("schema", "migrations", "0002_categories_v2.sql")
def _migrate_0002(db, sql):
"""Applies 0002 the way sqlx does: one transaction, foreign keys on (set before it starts)."""
db.isolation_level = None
db.execute("BEGIN")
try:
for stmt in _split_sql(sql):
db.execute(stmt)
db.execute("COMMIT")
except sqlite3.Error:
db.execute("ROLLBACK")
raise
def _split_sql(sql):
out, buf = [], ""
for line in sql.splitlines(True):
buf += line
if sqlite3.complete_statement(buf):
out.append(buf)
buf = ""
if buf.strip() and not all(l.strip().startswith("--") or not l.strip() for l in buf.splitlines()):
out.append(buf)
return out
def check_migration_0002():
path = os.path.join(CONTRACTS, MIGRATION_0002)
v1 = os.path.join(CONTRACTS, "schema", "server-v1.sql")
if not os.path.exists(path):
report(False, "schema 0002_categories_v2.sql exists", "missing")
return
with open(path, encoding="utf-8") as f:
sql = f.read()
with open(v1, encoding="utf-8") as f:
v1sql = f.read()
if re.search(r"^\s*(BEGIN|COMMIT|END|ROLLBACK)\b", sql, re.M | re.I) or re.search(r"PRAGMA\s+foreign_keys\b", sql, re.I):
report(False, "schema 0002 has no transaction control or foreign_keys pragma (sqlx wraps it)", "found one")
else:
report(True, "schema 0002 has no transaction control or foreign_keys pragma (sqlx wraps it)")
def fresh():
db = sqlite3.connect(":memory:")
db.execute("PRAGMA foreign_keys = ON")
db.executescript(v1sql)
return db
# 1. empty v1 database
db = fresh()
try:
_migrate_0002(db, sql)
except sqlite3.Error as e:
report(False, "schema 0002 loads on top of server-v1.sql (foreign keys on, one transaction)", str(e))
return
fk = db.execute("PRAGMA foreign_key_check").fetchall()
integ = db.execute("PRAGMA integrity_check").fetchone()[0]
report(not fk and integ == "ok", "schema 0002 loads on top of server-v1.sql (foreign keys on, one transaction)",
"foreign_key_check %r, integrity %s" % (fk, integ) if (fk or integ != "ok") else "SQLite %s" % sqlite3.sqlite_version)
meta = dict(db.execute("SELECT key, value FROM meta"))
seq = db.execute("SELECT id, value FROM sync_sequence").fetchall()
rows = db.execute("SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, "
"deleted_at, server_version FROM categories ORDER BY sort_order").fetchall()
want = sorted([(i, k, None, ic, co, so, "2026-10-17T00:00:00.000Z", "2026-10-17T00:00:00.000Z", None, None, n + 1)
for n, (i, k, ic, co, so) in enumerate(V1_DEFAULT_CATEGORIES)]
+ [(i, k, None, ic, co, so, "2026-10-18T00:00:00.000Z", "2026-10-18T00:00:00.000Z", None, None, 11 + n)
for n, (i, k, ic, co, so) in enumerate(V2_NEW_CATEGORIES)], key=lambda r: r[5])
ok = meta.get("schema_version") == "2" and seq == [(1, 14)] and rows == want
report(ok, "schema 0002 on a fresh database: 14 default categories, new server_versions 11..14, sync_sequence 14, schema_version 2",
"meta %r, sequence %r, categories %r" % (meta, seq, rows) if not ok else "")
keys = [r[1] for r in rows]
report(sorted(keys) == sorted(DEFAULT_KEYS) and all(r[3] in ICONS and r[4] in COLORS for r in rows),
"schema 0002 default keys, icons and colours are the checker's §9.7 / AC-7.2 lists", "keys %r" % keys)
try:
db.execute("INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, server_version) "
"VALUES ('x', 'pets', NULL, 'pets', 'lime', 1, 't', 't', 999)")
report(False, "schema 0002 keeps a default_key CHECK", "an unknown default_key was accepted")
except sqlite3.IntegrityError:
report(True, "schema 0002 keeps a default_key CHECK")
seed_sql = (
"INSERT INTO users VALUES ('u1', 'admin', 'A', NULL, 't', 't', 11);"
"INSERT INTO accounts VALUES ('a1', 'Vardag', 'vardag', 'u1', 't', 't', NULL, NULL, 12);"
"INSERT INTO categories VALUES ('0192a007-0000-7000-8000-000000000001', NULL, 'Husdjur', 'pets', 'lime', 110, 't', 't', 't', NULL, 13);"
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, expense_date, payer_user_id, split_mode,"
" created_by_user_id, updated_by_user_id, created_at, updated_at, server_received_at, server_version)"
" VALUES ('e1', 'a1', '01920000-0000-7000-8000-000000000001', 100, 'Ica', '2026-10-01', 'u1', 'default', 'u1', 'u1', 't', 't', 't', 14),"
" ('e2', 'a1', '0192a007-0000-7000-8000-000000000001', 200, 'Hundmat', '2026-10-02', 'u1', 'default', 'u1', 'u1', 't', 't', 't', 15);"
"INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, payer_user_id, split_mode, day_of_month,"
" start_period, created_by_user_id, updated_by_user_id, created_at, updated_at, server_version)"
" VALUES ('r1', 'a1', '01920000-0000-7000-8000-000000000004', 300, 'El', 'u1', 'default', 1, '2026-10', 'u1', 'u1', 't', 't', 16);"
"UPDATE categories SET name = 'Matvaror', server_version = 17 WHERE default_key = 'groceries';"
"UPDATE sync_sequence SET value = 917 WHERE id = 1;")
db.close()
# 2. a v1 database in use: renamed default, custom archived category, expenses and a template referring to categories
db = fresh()
db.executescript(seed_sql)
before = db.execute("SELECT * FROM categories ORDER BY id").fetchall()
try:
_migrate_0002(db, sql)
fk = db.execute("PRAGMA foreign_key_check").fetchall()
after = db.execute("SELECT * FROM categories WHERE id NOT IN (%s) ORDER BY id" % ",".join("'%s'" % c[0] for c in V2_NEW_CATEGORIES)).fetchall()
new = db.execute("SELECT default_key, server_version FROM categories WHERE created_at = '2026-10-18T00:00:00.000Z' ORDER BY server_version").fetchall()
seq = db.execute("SELECT value FROM sync_sequence").fetchone()[0]
refs = db.execute("SELECT count(*) FROM expenses e JOIN categories c ON c.id = e.category_id").fetchone()[0]
defer = db.execute("PRAGMA defer_foreign_keys").fetchone()[0]
ok = (not fk and after == before and new == [("housing", 918), ("insurance", 919), ("children", 920), ("fees", 921)]
and seq == 921 and refs == 2 and defer == 0)
report(ok, "schema 0002 on a database in use: rows kept, references intact, new rows take 918..921 from the sequence",
"fk %r, kept %s, new %r, sequence %r, refs %r, defer %r" % (fk, after == before, new, seq, refs, defer) if not ok else "")
except sqlite3.Error as e:
report(False, "schema 0002 on a database in use: rows kept, references intact, new rows take 918..921 from the sequence", str(e))
db.close()
# 3. the deferred check still catches a dangling reference: drop the referenced row inside the same transaction
db = fresh()
db.executescript(seed_sql)
try:
_migrate_0002(db, sql + "\nDELETE FROM categories WHERE id = '0192a007-0000-7000-8000-000000000001';\n")
report(False, "schema 0002 keeps foreign keys enforced (a dangling reference fails the migration)", "a dangling expense reference was committed")
except sqlite3.IntegrityError:
report(True, "schema 0002 keeps foreign keys enforced (a dangling reference fails the migration)")
db.close()
server_copy = os.path.join(ROOT, "server", "crates", "kvitt-server", "migrations", "0002_categories_v2.sql")
if os.path.exists(server_copy):
with open(server_copy, encoding="utf-8") as f:
same = f.read() == sql
report(same, "schema server migrations/0002_categories_v2.sql is byte-identical to the contract", "" if same else "differs")
# ----------------------------------------------------------------------------------------------------------------------
# Keyword table of the category suggestion (UX-3, CS-6)
# ----------------------------------------------------------------------------------------------------------------------
KEYWORDS_CSV = "category-keywords.csv"
KEYWORDS_HEADER = ["keyword", "match", "default_key", "source"]
def load_keywords():
path = os.path.join(CONTRACTS, KEYWORDS_CSV)
if not os.path.exists(path):
return None, None
with open(path, "rb") as f:
raw = f.read()
return raw, list(csv.reader(io.StringIO(raw.decode("utf-8"), newline="")))
def check_keywords():
raw, rows = load_keywords()
if raw is None:
report(False, "keywords %s exists" % KEYWORDS_CSV, "missing")
return []
problems = []
if raw.startswith(b"\xef\xbb\xbf") or b"\r" in raw or not raw.endswith(b"\n") or raw.endswith(b"\n\n"):
problems.append("must be UTF-8 without BOM, LF line ends, one final newline")
if not rows or rows[0] != KEYWORDS_HEADER:
problems.append("header must be %s" % ",".join(KEYWORDS_HEADER))
entries = []
for n, r in enumerate(rows[1:], start=2):
if len(r) != 4:
problems.append("line %d: %d columns" % (n, len(r)))
continue
kw, kind, key, source = r
if kind not in KEYWORD_MATCHES:
problems.append("line %d: match %r" % (n, kind))
if key not in DEFAULT_KEYS or key == "other":
problems.append("line %d: default_key %r (one of the 14 keys, never other)" % (n, key))
if source not in ("data", "curated"):
problems.append("line %d: source %r" % (n, source))
if not kw or normalize_description(kw) != kw:
problems.append("line %d: keyword %r is not normalised (CS-4 gives %r)" % (n, kw, normalize_description(kw)))
if (kind == "phrase") != (" " in kw):
problems.append("line %d: %r: a phrase has two or more tokens, a token or prefix exactly one" % (n, kw))
entries.append({"keyword": kw, "match": kind, "default_key": key, "source": source})
kws = [e["keyword"] for e in entries]
dup = sorted(set(k for k in kws if kws.count(k) > 1))
if dup:
problems.append("duplicate keywords %s" % dup)
order = [(e["default_key"], e["keyword"]) for e in entries]
if order != sorted(order):
bad = [b for a, b in zip(order, order[1:]) if a > b]
problems.append("not sorted by default_key, then keyword (code points); first out of order %r" % (bad[:1],))
unused = sorted(set(k for k in DEFAULT_KEYS if k != "other") - set(e["default_key"] for e in entries))
detail = "%d entries (%s), every default key but other covered%s" % (
len(entries), ", ".join("%d %s" % (sum(1 for e in entries if e["match"] == m), m) for m in KEYWORD_MATCHES),
"" if not unused else "; keys without entries: %s" % unused)
report(not problems, "keywords %s: header, match kinds, default keys, normalised, unique, sorted" % KEYWORDS_CSV,
"; ".join(problems[:6]) if problems else detail)
return entries
def check_vector_keywords(table):
"""Every keyword list in suggest_category.json is a subsequence of the table (same entries, table order)."""
path = os.path.join(CONTRACTS, "test-vectors", "suggest_category.json")
if not table or not os.path.exists(path):
return
try:
with open(path, encoding="utf-8") as f:
cases = json.load(f)["cases"]
except (ValueError, KeyError, TypeError):
return
index = dict((e["keyword"], (n, e["match"], e["default_key"])) for n, e in enumerate(table))
bad = []
for c in cases:
try:
pos = [index.get(k["keyword"]) for k in c["input"]["keywords"]]
ok = (all(p is not None and (p[1], p[2]) == (k["match"], k["default_key"]) for p, k in zip(pos, c["input"]["keywords"]))
and [p[0] for p in pos] == sorted(set(p[0] for p in pos)))
except (KeyError, TypeError):
ok = False
if not ok:
bad.append(c.get("name"))
report(not bad, "vectors suggest_category.json keyword lists are taken from %s in table order" % KEYWORDS_CSV,
"cases %s" % bad[:5] if bad else "%d cases" % len(cases))
# ----------------------------------------------------------------------------------------------------------------------
# OpenAPI (§7, §14.3) — text-level check
# ----------------------------------------------------------------------------------------------------------------------
ENDPOINTS = [
("E-1", "get", "/health"), ("E-2", "get", "/setup/status"), ("E-3", "post", "/invites/accept"),
("E-4", "get", "/i/{code}"), ("E-5", "get", "/apk/latest"), ("E-6", "get", "/apk/files/{file_name}"),
("E-7", "get", "/me"), ("E-8", "patch", "/me"), ("E-9", "put", "/me/picture"), ("E-10", "delete", "/me/picture"),
("E-11", "get", "/users/{user_id}/picture"), ("E-12", "post", "/invites"), ("E-13", "get", "/invites"),
("E-14", "delete", "/invites/{invite_id}"), ("E-15", "get", "/devices"), ("E-16", "delete", "/devices/{device_id}"),
("E-17", "put", "/devices/current/push-token"), ("E-18", "post", "/accounts"),
("E-19", "patch", "/accounts/{account_id}"), ("E-20", "post", "/accounts/{account_id}/archive"),
("E-21", "post", "/accounts/{account_id}/unarchive"), ("E-22", "delete", "/accounts/{account_id}"),
("E-23", "post", "/categories"), ("E-24", "patch", "/categories/{category_id}"),
("E-25", "post", "/categories/{category_id}/archive"), ("E-26", "post", "/categories/{category_id}/unarchive"),
("E-27", "delete", "/categories/{category_id}"), ("E-28", "post", "/recurring-templates"),
("E-29", "patch", "/recurring-templates/{template_id}"), ("E-30", "delete", "/recurring-templates/{template_id}"),
("E-31", "get", "/sync"), ("E-32", "post", "/sync"), ("E-33", "post", "/settlements/proposal"),
]
UNAUTHENTICATED = {"E-1", "E-2", "E-3", "E-4", "E-5", "E-6"}
def check_openapi():
path = os.path.join(CONTRACTS, "openapi.yaml")
if not os.path.exists(path):
report(False, "openapi.yaml exists", "missing")
return
with open(path, encoding="utf-8") as f:
lines = f.read().splitlines()
text = "\n".join(lines)
head_ok = (re.search(r"^openapi: 3\.1\.0$", text, re.M) and re.search(r"^ title: Kvitt$", text, re.M)
and re.search(r"^ version: '1'$", text, re.M) and re.search(r"^ - url: https://\{host\}:8443\n variables:\n host:\n default: kvitt\.example\.invalid$", text, re.M))
report(bool(head_ok), "openapi header (3.1.0, title Kvitt, version 1, one server)", "" if head_ok else "header fields differ from §14.3")
sec_ok = re.search(r"^ device:\n type: http\n scheme: bearer$", text, re.M) is not None
report(sec_ok, "openapi security scheme device = HTTP bearer (§8.2)", "" if sec_ok else "components.securitySchemes.device not found")
# paths: path keys at indent 2, method keys at indent 4, each operation carries `summary: E-n …`
ops, in_paths, cur_path, cur_method = {}, False, None, None
for ln in lines:
if re.match(r"^\S", ln):
in_paths = ln.startswith("paths:")
continue
if not in_paths:
continue
m = re.match(r"^ (/\S*):\s*$", ln)
if m:
cur_path = m.group(1)
continue
m = re.match(r"^ (get|put|post|patch|delete|head|options|trace):\s*$", ln)
if m and cur_path:
cur_method = m.group(1)
ops[(cur_method, cur_path)] = {"eid": None, "security": None}
continue
m = re.match(r"^ summary: '?(E-\d+)\b", ln)
if m and cur_method:
ops[(cur_method, cur_path)]["eid"] = m.group(1)
m = re.match(r"^ security: (.*)$", ln)
if m and cur_method:
ops[(cur_method, cur_path)]["security"] = m.group(1).strip()
want = {(meth, p): eid for eid, meth, p in ENDPOINTS}
got = {k: v["eid"] for k, v in ops.items()}
report(got == want, "openapi operations are exactly E-1..E-33 of §7",
"missing %s, extra %s, wrong ids %s" % (sorted(set(want) - set(got)), sorted(set(got) - set(want)),
sorted(k for k in want if k in got and got[k] != want[k])) if got != want else "%d operations" % len(got))
bad_sec = sorted(eid for (meth, p), eid in want.items() if (meth, p) in ops and
((ops[(meth, p)]["security"] == "[]") != (eid in UNAUTHENTICATED)))
report(not bad_sec, "openapi security: E-1..E-6 unauthenticated, all others device", "wrong: %s" % bad_sec if bad_sec else "")
m = re.search(r"^ ErrorCode:\n((?: .*\n|\s*\n)+)", text + "\n", re.M)
codes = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else []
report(codes == ERROR_CODES, "openapi ErrorCode enum is the exhaustive list of §14.3.2",
"got %s" % codes if codes != ERROR_CODES else "%d codes" % len(codes))
m = re.search(r"^ RejectionReason:\n((?: .*\n|\s*\n)+)", text + "\n", re.M)
reasons = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else []
report(reasons == REASON[1], "openapi RejectionReason enum is the exhaustive list of §14.4.6", "got %s" % reasons if reasons != REASON[1] else "")
m = re.search(r"^ DefaultCategoryKey:\n type: string\n enum: \[([^\]]*)\]$", text, re.M)
keys = [k.strip() for k in m.group(1).split(",")] if m else []
report(keys == DEFAULT_KEYS, "openapi DefaultCategoryKey enum is the 14 default keys (AC-7.2, UX-8)",
"got %s" % keys if keys != DEFAULT_KEYS else "%d keys" % len(keys))
leaked = [t for t in ("ColorToken", "IconToken") if re.search(r"^ %s:" % t, text, re.M)]
icon_plain = re.search(r"^ icon:\n type: string\n", text, re.M) is not None
color_plain = re.search(r"^ color:\n type: string\n", text, re.M) is not None
report(not leaked and icon_plain and color_plain,
"openapi icon and color stay plain strings (§9.7 tokens, incl. key, shield, account_balance, slate, amber, are checked in code)",
"token enums %s, icon plain %s, color plain %s" % (leaked, icon_plain, color_plain) if (leaked or not icon_plain or not color_plain) else "")
schemas = set(re.findall(r"^ ([A-Z]\w*):$", text, re.M))
need = {"User", "Device", "Invite", "Share", "Account", "Category", "Split", "TemplateSplit", "Expense", "Allocation",
"Settlement", "RecurringTemplate", "AuditEntry", "Error"}
report(need <= schemas, "openapi declares every wire object of §14.3.3", "missing %s" % sorted(need - schemas) if not need <= schemas else "")
# ----------------------------------------------------------------------------------------------------------------------
def check_json_parses():
bad, n = [], 0
for dirpath, _, files in os.walk(CONTRACTS):
for f in files:
if f.endswith(".json"):
n += 1
p = os.path.join(dirpath, f)
try:
with open(p, encoding="utf-8") as fh:
json.load(fh)
except ValueError as e:
bad.append("%s (%s)" % (rel(p), e))
report(not bad and n > 0, "json: every file under contracts/ parses", "; ".join(bad) if bad else "%d files" % n)
def main():
if not os.path.isdir(CONTRACTS):
report(False, "contracts/ exists", "no such directory: %s" % CONTRACTS)
else:
check_json_parses()
counts = check_vectors()
table = check_keywords()
check_vector_keywords(table)
check_examples()
check_schema()
check_migration_0002()
check_openapi()
if counts:
print("vector cases: " + ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items())))
print("%d checks passed, %d failed" % (PASSES[0], len(FAILURES)))
return 1 if FAILURES else 0
if __name__ == "__main__":
sys.exit(main())