Review fixes: grant keyed on Gitea id (atomic), strict agent ids, idempotency bound to request, finite amounts

This commit is contained in:
Leora 2026-10-10 10:31:13 +08:00
parent ab49a82881
commit a523b8a65a
4 changed files with 147 additions and 45 deletions

View File

@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import math
from dataclasses import asdict from dataclasses import asdict
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -261,19 +264,14 @@ class CofferProduct:
# ----- public API ---------------------------------------------------------- # ----- public API ----------------------------------------------------------
def create_wallet(self, account_id: str) -> dict[str, Any]: def create_wallet(self, account_id: str) -> dict[str, Any]:
if account_id.startswith(("company:", "human:")): aid = human_account(account_id) if account_id.startswith("human:") else agent_account(account_id)
aid = account_id
else:
aid = agent_account(account_id)
self.ledger.ensure_wallet(aid) self.ledger.ensure_wallet(aid)
self._ensure_account_row(aid) self._ensure_account_row(aid)
self.db.commit() self.db.commit()
return {"account_id": aid, "balance": self.balance(aid)} return {"account_id": aid, "balance": self.balance(aid)}
def balance(self, account_id: str) -> dict[str, Any]: def balance(self, account_id: str) -> dict[str, Any]:
aid = account_id aid = human_account(account_id) if account_id.startswith("human:") else agent_account(account_id)
if not aid.startswith(("agent:", "company:", "human:")):
aid = agent_account(aid)
raw = self.ledger.balance_subunits(aid) raw = self.ledger.balance_subunits(aid)
return { return {
"account_id": aid, "account_id": aid,
@ -471,44 +469,97 @@ class CofferProduct:
"tx": self.ledger.describe_tx(txs[-1]), "tx": self.ledger.describe_tx(txs[-1]),
} }
# ----- people: prepaid credit purchases and paying agents ------------------ # ----- people: wallets, the signup grant, and payments ---------------------
def human_wallet(self, handle: str) -> dict[str, Any]: @contextlib.contextmanager
return self.create_wallet(human_account(handle)) def _atomic(self):
"""One writer at a time across processes: BEGIN IMMEDIATE, reload from the DB, mutate,
commit (via _commit_accounts). On any error: roll back and reload."""
self.db.commit()
self.db.execute("BEGIN IMMEDIATE")
try:
self._load()
yield
except BaseException:
self.db._conn.rollback()
self._load()
raise
def human_signup(self, handle: str) -> dict[str, Any]: def _claim(self, key: str, request: str) -> str | None:
"""A person's wallet plus the same free signup grant agents get (once per person). """Insert an idempotency key first (inside _atomic). Returns the stored value if the key
People pay no subscription seat.""" already existed; raises if it existed for a different request."""
aid = human_account(handle)
wallet = self.create_wallet(aid)
grant = self.grant_signup(aid)
return {"wallet": wallet, "grant": grant, "balance": self.balance(aid)}
def _idem(self, key: str) -> str | None:
row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone() row = self.db.execute("SELECT order_id FROM idempotency WHERE key=?", (key,)).fetchone()
return row["order_id"] if row else None if row is not None:
stored = row["order_id"]
if stored.split("|", 1)[-1] != request:
raise ProductError("idempotency key reused for a different request")
return stored
self.db.execute("INSERT INTO idempotency(key, order_id) VALUES(?,?)", (key, "pending|" + request))
return None
def _settle_claim(self, key: str, seq: int, request: str) -> None:
self.db.execute("UPDATE idempotency SET order_id=? WHERE key=?", (f"{seq}|{request}", key))
def _tx_by_seq(self, seq: int) -> dict[str, Any]: def _tx_by_seq(self, seq: int) -> dict[str, Any]:
tx = next(t for t in self.ledger.transactions if t.seq == seq) tx = next(t for t in self.ledger.transactions if t.seq == seq)
return self.ledger.describe_tx(tx) return self.ledger.describe_tx(tx)
def human_wallet(self, handle: str) -> dict[str, Any]:
return self.create_wallet(human_account(handle))
def human_signup(self, handle: str, gitea_id: str | int) -> dict[str, Any]:
"""A person's wallet plus the same free signup grant agents get. Once per Gitea user id
(immutable), so renaming a Gitea account cannot claim it again. Atomic across processes.
People pay no subscription seat."""
aid = human_account(handle)
gid = str(gitea_id or "")
if not gid.isdigit():
raise ProductError("gitea_id is required")
key = f"signup_grant:gitea:{gid}"
with self._atomic():
seen = self._claim(key, aid)
if seen is not None:
self.db.commit()
return {"already": True, "account_id": aid, "balance": self.balance(aid)}
self.ledger.ensure_wallet(aid)
self._ensure_account_row(aid)
before = self.ledger._tx_seq
self.ledger.grant(aid, SIGNUP_GRANT_CREDITS, reason="signup-grant")
txs = [t for t in self.ledger.transactions if t.seq > before]
self._settle_claim(key, txs[-1].seq, aid)
self._commit_accounts(aid, txs=txs)
return {"already": False, "account_id": aid, "granted_credits": SIGNUP_GRANT_CREDITS,
"balance": self.balance(aid), "tx": self.ledger.describe_tx(txs[-1])}
def pay(self, payer: str, payee: str, credits: float, *, reason: str, ref: str) -> dict[str, Any]: def pay(self, payer: str, payee: str, credits: float, *, reason: str, ref: str) -> dict[str, Any]:
"""Pay between people / a person and an agent, once per ref (e.g. an offer id).""" """Pay between people / a person and an agent, once per ref (e.g. an offer id). The ref
if not ref or len(ref) > 80: is bound to (payer, payee, amount): replaying it with anything else is refused."""
if not isinstance(ref, str) or not ref or len(ref) > 80 or "|" in ref:
raise ProductError("ref is required") raise ProductError("ref is required")
if isinstance(credits, bool) or not isinstance(credits, (int, float)) or not math.isfinite(credits):
raise ProductError("amount must be a finite number")
amount = round(float(credits) * SCALE) amount = round(float(credits) * SCALE)
if amount <= 0 or amount > MAX_PAYMENT_CREDITS * SCALE: if amount <= 0 or amount > MAX_PAYMENT_CREDITS * SCALE:
raise ProductError(f"amount must be between 0.01 and {MAX_PAYMENT_CREDITS} credits") raise ProductError(f"amount must be between 0.01 and {MAX_PAYMENT_CREDITS} credits")
payer_id = human_account(payer) if payer.startswith("human:") else agent_account(payer)
payee_id = human_account(payee) if payee.startswith("human:") else agent_account(payee)
request = f"{payer_id}>{payee_id}:{amount}"
key = f"pay:{ref}" key = f"pay:{ref}"
seen = self._idem(key) with self._atomic():
if seen: seen = self._claim(key, request)
return {"duplicate": True, "tx": self._tx_by_seq(int(seen)), "balance": self.balance(payer)} if seen is not None:
self.create_wallet(payer) self.db.commit()
self.create_wallet(payee) seq = seen.split("|", 1)[0]
tx = self.ledger.pay(payer, payee, amount, (reason or "service")[:120], meta={"offer": ref}) if not seq.isdigit():
self.db.execute("INSERT INTO idempotency(key, order_id) VALUES(?,?)", (key, str(tx.seq))) raise ProductError("payment in progress")
self._commit_accounts(payer, payee, txs=[tx]) return {"duplicate": True, "tx": self._tx_by_seq(int(seq)), "balance": self.balance(payer_id)}
return {"duplicate": False, "tx": self.ledger.describe_tx(tx), "balance": self.balance(payer)} for aid in (payer_id, payee_id):
self.ledger.ensure_wallet(aid)
self._ensure_account_row(aid)
tx = self.ledger.pay(payer_id, payee_id, amount, (reason or "service")[:120], meta={"offer": ref})
self._settle_claim(key, tx.seq, request)
self._commit_accounts(payer_id, payee_id, txs=[tx])
return {"duplicate": False, "tx": self.ledger.describe_tx(tx), "balance": self.balance(payer_id)}
def all_balances(self) -> dict[str, dict]: def all_balances(self) -> dict[str, dict]:
return {aid: self.balance(aid) for aid in sorted(self.ledger.accounts.keys())} return {aid: self.balance(aid) for aid in sorted(self.ledger.accounts.keys())}

View File

@ -58,8 +58,10 @@ def agent_account(agent_id: str) -> str:
aid = (agent_id or "").strip() aid = (agent_id or "").strip()
if not aid: if not aid:
raise ValueError("agent_id is required") raise ValueError("agent_id is required")
if aid.startswith(("agent:", "company:", "human:")): if aid.startswith("agent:") or aid.startswith("company:"):
return aid return aid
if aid.startswith("human:"):
raise ValueError("not an agent account (people have their own entry points)")
return f"agent:{aid}" return f"agent:{aid}"
@ -68,8 +70,8 @@ def human_account(handle: str) -> str:
h = (handle or "").strip() h = (handle or "").strip()
if h.startswith("human:"): if h.startswith("human:"):
h = h[len("human:"):] h = h[len("human:"):]
if not h or not all(c.isalnum() or c in "-_." for c in h) or len(h) > 40: if not h or not all(c.isascii() and (c.isalnum() or c == "-") for c in h) or len(h) > 40 or h != h.lower():
raise ValueError("a human handle is required") raise ValueError("a human handle is required (a-z, 0-9, -)")
return f"human:{h}" return f"human:{h}"

View File

@ -75,11 +75,18 @@ People take part in the economy the same way agents do, inside the same rules.
- **Wallet:** `human:<handle>`, one per person (their Gitea account linked to messaging). The - **Wallet:** `human:<handle>`, one per person (their Gitea account linked to messaging). The
public ledger shows the handle only, never a real name or email. public ledger shows the handle only, never a real name or email.
- **Signup grant:** the same free 40-credit grant as agents (`human_signup`, once per person). - **Signup grant:** the same free 40-credit grant as agents (`human_signup(handle, gitea_id)`),
People pay no subscription seat. once per **Gitea user id** (immutable; a renamed account cannot claim again). The claim key is
inserted first inside `BEGIN IMMEDIATE`, so concurrent claims from separate processes give one
grant. People pay no subscription seat. The platform also caps new people's grants per day.
- **Agent-only entry points stay agent-only:** `agent_account()` refuses `human:` ids, so
`grant_signup`, `top_up`, `charge_subscription`, store orders and the CLI cannot touch a
person's wallet.
- **No purchases:** there is no way to buy credit for now (no packs, no test mode, no provider). - **No purchases:** there is no way to buy credit for now (no packs, no test mode, no provider).
Prepaid only and no cash-out, as before. Prepaid only and no cash-out, as before.
- **Paying:** `pay(payer, payee, credits, reason=..., ref=...)` moves credit person -> agent, - **Paying:** `pay(payer, payee, credits, reason=..., ref=...)` moves credit person -> agent,
agent -> person or person -> person, once per `ref` (the platform's offer id). Granted and agent -> person or person -> person, once per `ref` (the platform's offer id). The ref is
bound to (payer, payee, amount): replaying it with anything else is refused. Amounts must be
finite numbers. Granted and
funded credit can both be paid (granted first); the payee receives the same kinds that were funded credit can both be paid (granted first); the payee receives the same kinds that were
drawn. Agent -> agent stays blocked (the existing peer rule). Max 1000 credits per payment. drawn. Agent -> agent stays blocked (the existing peer rule). Max 1000 credits per payment.

View File

@ -22,19 +22,61 @@ class PeopleTests(unittest.TestCase):
self.tmp.cleanup() self.tmp.cleanup()
def test_human_signup_grant_once(self): def test_human_signup_grant_once(self):
r = self.p.human_signup("sam") r = self.p.human_signup("sam", 101)
self.assertEqual(r["wallet"]["account_id"], "human:sam") self.assertEqual(r["account_id"], "human:sam")
self.assertEqual(r["balance"]["promotional"], 40.0) self.assertEqual(r["balance"]["promotional"], 40.0)
self.assertTrue(self.p.human_signup("sam")["grant"]["already"]) self.assertTrue(self.p.human_signup("sam", 101)["already"])
self.assertEqual(self.p.balance("human:sam")["spendable"], 40.0) self.assertEqual(self.p.balance("human:sam")["spendable"], 40.0)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
human_account("Sam Smith <sam@example.com>") human_account("Sam Smith <sam@example.com>")
def test_grant_keyed_on_gitea_id_rename_and_concurrency(self):
self.p.human_signup("sam", 101)
with self.assertRaises(ProductError): # rename: the Gitea id already had its grant
self.p.human_signup("sam-renamed", 101)
self.assertEqual(self.p.balance("human:sam-renamed")["spendable"], 0.0)
import threading
results, errors = [], []
def claim():
p = CofferProduct(self.db) # separate connection, like separate platform requests
try:
results.append(p.human_signup("ana", 555)["already"])
except Exception as exc: # noqa: BLE001
errors.append(exc)
finally:
p.close()
threads = [threading.Thread(target=claim) for _ in range(8)]
[t.start() for t in threads]
[t.join() for t in threads]
self.p.close()
self.p = CofferProduct(self.db)
self.assertEqual(self.p.balance("human:ana")["spendable"], 40.0)
self.assertEqual(results.count(False), 1, (results, errors))
def test_agent_only_entry_points_reject_people(self):
self.p.human_signup("sam", 101)
for fn in (lambda: self.p.grant_signup("human:sam"), lambda: self.p.top_up("human:sam", 5),
lambda: self.p.charge_subscription("human:sam"), lambda: self.p.signup_flow("human:sam")):
with self.assertRaises(ValueError):
fn()
self.assertEqual(self.p.balance("human:sam")["spendable"], 40.0)
def test_payment_ref_bound_to_request(self):
self.p.human_signup("sam", 101)
self.p.human_signup("kim", 102)
self.p.pay("human:sam", "agent:atlas", 2, reason="x", ref="of-r")
for args in (("human:kim", "agent:atlas", 2), ("human:sam", "human:kim", 2), ("human:sam", "agent:atlas", 3)):
with self.assertRaises(ProductError):
self.p.pay(*args, reason="x", ref="of-r")
for bad in (float("nan"), float("inf"), "5", True):
with self.assertRaises(ProductError):
self.p.pay("human:sam", "agent:atlas", bad, reason="x", ref="of-bad")
def test_no_purchase_api(self): def test_no_purchase_api(self):
self.assertFalse(hasattr(self.p, "buy_credits")) self.assertFalse(hasattr(self.p, "buy_credits"))
def test_human_pays_agent_with_granted_credit(self): def test_human_pays_agent_with_granted_credit(self):
self.p.human_signup("sam") self.p.human_signup("sam", 101)
before = self.p.balance("agent:atlas") before = self.p.balance("agent:atlas")
r = self.p.pay("human:sam", "agent:atlas", 12.5, reason="Research brief", ref="of-1") r = self.p.pay("human:sam", "agent:atlas", 12.5, reason="Research brief", ref="of-1")
self.assertEqual(r["tx"]["kind"], "payment") self.assertEqual(r["tx"]["kind"], "payment")
@ -48,8 +90,8 @@ class PeopleTests(unittest.TestCase):
self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="x", ref="of-1")["duplicate"]) self.assertTrue(self.p.pay("human:sam", "agent:atlas", 12.5, reason="x", ref="of-1")["duplicate"])
def test_agent_pays_human_and_people_pay_each_other(self): def test_agent_pays_human_and_people_pay_each_other(self):
self.p.human_signup("sam") self.p.human_signup("sam", 101)
self.p.human_signup("kim") self.p.human_signup("kim", 102)
self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-2") self.p.pay("agent:atlas", "human:sam", 5, reason="bounty", ref="of-2")
self.assertEqual(self.p.balance("human:sam")["spendable"], 45.0) self.assertEqual(self.p.balance("human:sam")["spendable"], 45.0)
self.p.pay("human:sam", "human:kim", 10, reason="thanks", ref="of-3") self.p.pay("human:sam", "human:kim", 10, reason="thanks", ref="of-3")
@ -57,7 +99,7 @@ class PeopleTests(unittest.TestCase):
self.assertEqual(self.p.balance("human:sam")["spendable"], 35.0) self.assertEqual(self.p.balance("human:sam")["spendable"], 35.0)
def test_rules(self): def test_rules(self):
self.p.human_signup("sam") self.p.human_signup("sam", 101)
self.p.signup_flow("bolt") self.p.signup_flow("bolt")
with self.assertRaises(TransferNotAllowed): with self.assertRaises(TransferNotAllowed):
self.p.pay("agent:atlas", "agent:bolt", 1, reason="x", ref="of-4") self.p.pay("agent:atlas", "agent:bolt", 1, reason="x", ref="of-4")
@ -69,7 +111,7 @@ class PeopleTests(unittest.TestCase):
self.p.pay("human:sam", "agent:atlas", 41, reason="x", ref="of-7") self.p.pay("human:sam", "agent:atlas", 41, reason="x", ref="of-7")
def test_public_ledger_shows_handles_only(self): def test_public_ledger_shows_handles_only(self):
self.p.human_signup("sam") self.p.human_signup("sam", 101)
self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8") self.p.pay("human:sam", "agent:atlas", 2, reason="Review", ref="of-8")
pub = [tx_public(t) for t in self.p.history()] pub = [tx_public(t) for t in self.p.history()]
self.assertIn("human:sam", repr(pub)) self.assertIn("human:sam", repr(pub))