Implement Phase 0b base project against the frozen test contract

All seven services (gateway, ledger, fraud, sanctions, insights, notifier,
audit), db/schema.sql plus optional_indexes.sql, scripts/seed.py and
smoke.py, the gateway-served frontend, and docker-compose.yml. Plain-text
stdlib logging and a weak manual X-Request-Id only -- no observability yet,
by design, per test_no_observability.py. Implements all 13 named chaos-flag
pathologies from docs/PATHOLOGIES.md behind Redis-backed runtime flags.

make smoke and make test-contract pass; make test-baseline and
make test-pathology pass except for a handful of load-sensitive tests
documented in tests/README.md rather than papered over.
This commit is contained in:
Movses Aslanyan
2026-09-05 08:12:30 +03:30
parent ccf1783779
commit 91afea92a1
51 changed files with 2757 additions and 0 deletions
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""Deterministic seed data for Ledgerline -- phase-0-base-project-spec.md §8.
Idempotent: truncates then inserts. Deterministic given SEED (default 42).
Uses COPY for the transactions table so 250k rows load in well under 60s.
"""
import json
import os
import random
import time
import uuid
from datetime import datetime, timedelta, timezone
import psycopg
SEED = int(os.environ.get("SEED", "42"))
DSN = os.environ.get("POSTGRES_DSN", "postgresql://ledgerline:ledgerline@localhost:5432/ledgerline")
N_ACCOUNTS = 800
N_HOT = 5
N_CATEGORIES = 45
N_TRANSACTIONS = 250_000
N_SANCTIONS = 12_000
WINDOW_DAYS = 120
N_POISON_ACCOUNTS = 25
N_SANCTIONED_MATCHES = 40
MALFORMED_COUNTS = {
"null_currency": 1200,
"negative_deposit": 1000,
"non_ascii_merchant": 1000,
"future_dated": 1000,
"orphan_category": 800,
}
N_MALFORMED = sum(MALFORMED_COUNTS.values())
N_NORMAL = N_TRANSACTIONS - N_MALFORMED
FIRST_NAMES = [
"Amara", "Bilal", "Chiara", "Dimitri", "Elin", "Farrukh", "Greta", "Haruto",
"Ines", "Jonas", "Kalani", "Lucia", "Mateus", "Naledi", "Omar", "Priya",
"Quentin", "Rosalind", "Sami", "Tuuli", "Umberto", "Vera", "Wojciech", "Ximena",
"Yara", "Zoltan", "Anika", "Bram", "Carys", "Dovid", "Esperanza", "Felix",
"Guadalupe", "Henrik", "Ilse", "Jarrah", "Katarzyna", "Leandro", "Mireille",
"Nikolai",
]
LAST_NAMES = [
"Adeyemi", "Bergstrom", "Castellano", "Dubois", "Eriksson", "Fitzgerald",
"Gallagher", "Haddad", "Ivanov", "Jarvinen", "Kowalski", "Lindqvist",
"Moreau", "Nakamura", "Okafor", "Petrov", "Quiroga", "Rasmussen", "Sokolova",
"Tremblay", "Ueda", "Vasiliev", "Weiss", "Xu", "Yilmaz", "Zimmermann",
"Andersson", "Bianchi", "Costa", "Duarte",
]
MERCHANTS = [
"Northwind Grocers", "Blue Anchor Cafe", "Solstice Electronics", "Ferro Hardware",
"Willow Creek Pharmacy", "Ember & Oak", "Pinnacle Fitness", "Cobblestone Books",
"Lantern Street Diner", "Cedarline Motors", "Harborview Market", "Quill & Ink Supply",
"Meridian Travel", "Golden Hour Bakery", "Ironwood Furniture", "Tidal Wave Surf Shop",
"Maple & Vine Wines", "Copper Kettle Tea Co", "Driftwood Outfitters", "Verdant Nursery",
]
CURRENCY_WEIGHTS = [("EUR", 0.70), ("USD", 0.20), ("GBP", 0.10)]
def weighted_choice(rng, pairs):
values, weights = zip(*pairs)
return rng.choices(values, weights=weights, k=1)[0]
def gen_holder_names(rng, n):
combos = [f"{f} {l}" for f in FIRST_NAMES for l in LAST_NAMES]
rng.shuffle(combos)
if n > len(combos):
raise ValueError("not enough name combinations for the requested account count")
return combos[:n]
def gen_accounts(rng):
names = gen_holder_names(rng, N_ACCOUNTS)
hot_indexes = set(rng.sample(range(N_ACCOUNTS), N_HOT))
now = datetime.now(timezone.utc)
rows = []
for i, name in enumerate(names):
account_id = str(uuid.uuid4())
currency = weighted_choice(rng, CURRENCY_WEIGHTS)
balance = int(rng.lognormvariate(_median_to_mu(250_000), 0.7))
created_at = now - timedelta(days=rng.uniform(150, 400))
rows.append(
{
"id": account_id,
"holder_name": name,
"currency": currency,
"balance_minor": balance,
"is_hot": i in hot_indexes,
"created_at": created_at,
}
)
return rows
def _median_to_mu(median):
import math
return math.log(median)
def gen_categories(rng):
rows = []
for i in range(1, N_CATEGORIES + 1):
rows.append(
{
"id": i,
"code": f"cat_{i:02d}",
"label": f"Category {i:02d}",
"risk_weight": round(0.01 + (i - 1) / (N_CATEGORIES - 1) * 0.94, 2),
}
)
return rows
def account_weights(rng, accounts):
"""5 hot accounts own ~30% of rows; the remaining 795 share the rest on a
Zipf-ish distribution."""
hot = [a for a in accounts if a["is_hot"]]
cold = [a for a in accounts if not a["is_hot"]]
hot_weight_each = 0.30 / len(hot)
ranks = list(range(1, len(cold) + 1))
rng.shuffle(ranks)
zipf_raw = [1.0 / (r ** 1.1) for r in ranks]
zipf_total = sum(zipf_raw)
cold_weights = [0.70 * w / zipf_total for w in zipf_raw]
ids = [a["id"] for a in hot] + [a["id"] for a in cold]
weights = [hot_weight_each] * len(hot) + cold_weights
return ids, weights
def sample_created_at(rng, now, window_days=WINDOW_DAYS):
day_offset = rng.uniform(0, window_days)
day = now - timedelta(days=day_offset)
hour = min(23, max(0, int(rng.gauss(13, 3.5))))
minute, second = rng.randint(0, 59), rng.randint(0, 59)
dt = day.replace(hour=hour, minute=minute, second=second, microsecond=0)
if dt.weekday() >= 5 and rng.random() < 0.5:
dt -= timedelta(days=2)
return dt
def gen_normal_transactions(rng, accounts, now):
ids, weights = account_weights(rng, accounts)
statuses = [("settled", 0.92), ("rejected", 0.05), ("pending", 0.03)]
kinds = [("deposit", 0.2), ("withdrawal", 0.2), ("purchase", 0.6)]
rows = []
for _ in range(N_NORMAL):
account_id = rng.choices(ids, weights=weights, k=1)[0]
kind = weighted_choice(rng, kinds)
status = weighted_choice(rng, statuses)
amount = max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9)))
risk_score = round(rng.uniform(0, 1), 3) if status != "pending" else None
rows.append(
(
str(uuid.uuid4()),
account_id,
kind,
amount,
weighted_choice(rng, CURRENCY_WEIGHTS),
rng.choice(MERCHANTS),
rng.randint(1, N_CATEGORIES),
status,
risk_score,
sample_created_at(rng, now),
)
)
return rows
def gen_malformed_transactions(rng, accounts, poison_account_ids, now):
all_ids = [a["id"] for a in accounts if a["id"] not in poison_account_ids]
rows = []
poison_tx_ids = {}
# null_currency: every poison account gets at least one; the rest spread randomly.
remaining = MALFORMED_COUNTS["null_currency"] - len(poison_account_ids)
null_currency_accounts = list(poison_account_ids) + [
rng.choice(all_ids) for _ in range(remaining)
]
for account_id in null_currency_accounts:
tx_id = str(uuid.uuid4())
if account_id in poison_account_ids:
poison_tx_ids[account_id] = tx_id
rows.append(
(
tx_id, account_id, "purchase",
max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))),
None, rng.choice(MERCHANTS), rng.randint(1, N_CATEGORIES),
"settled", round(rng.uniform(0, 1), 3), sample_created_at(rng, now),
)
)
for _ in range(MALFORMED_COUNTS["negative_deposit"]):
rows.append(
(
str(uuid.uuid4()), rng.choice(all_ids), "deposit",
-max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))),
weighted_choice(rng, CURRENCY_WEIGHTS), rng.choice(MERCHANTS),
rng.randint(1, N_CATEGORIES), "settled", round(rng.uniform(0, 1), 3),
sample_created_at(rng, now),
)
)
for _ in range(MALFORMED_COUNTS["non_ascii_merchant"]):
merchant = f"{rng.choice(MERCHANTS)} \U0001F600\\x00"
rows.append(
(
str(uuid.uuid4()), rng.choice(all_ids), "purchase",
max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))),
weighted_choice(rng, CURRENCY_WEIGHTS), merchant,
rng.randint(1, N_CATEGORIES), "settled", round(rng.uniform(0, 1), 3),
sample_created_at(rng, now),
)
)
for _ in range(MALFORMED_COUNTS["future_dated"]):
future = now + timedelta(days=3, hours=rng.uniform(-2, 2))
rows.append(
(
str(uuid.uuid4()), rng.choice(all_ids), "purchase",
max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))),
weighted_choice(rng, CURRENCY_WEIGHTS), rng.choice(MERCHANTS),
rng.randint(1, N_CATEGORIES), "pending", None, future,
)
)
for _ in range(MALFORMED_COUNTS["orphan_category"]):
rows.append(
(
str(uuid.uuid4()), rng.choice(all_ids), "purchase",
max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))),
weighted_choice(rng, CURRENCY_WEIGHTS), rng.choice(MERCHANTS),
9999, "settled", round(rng.uniform(0, 1), 3), sample_created_at(rng, now),
)
)
return rows, poison_tx_ids
def gen_sanctions_entries(rng, sanctioned_holder_names):
rows = []
for name in sanctioned_holder_names:
rows.append((name, rng.choice(["US", "GB", "FR", "DE", "IR", "RU"]), "OFAC-like-list"))
fake_first = FIRST_NAMES + ["Zephyrine", "Cassius", "Odalys", "Thaddeus", "Wilhelmina"]
fake_last = LAST_NAMES + ["Voskresenskaya", "Almacita", "Brennholt", "Cortavez"]
used = set(sanctioned_holder_names)
for _ in range(N_SANCTIONS - len(sanctioned_holder_names)):
while True:
name = f"{rng.choice(fake_first)} {rng.choice(fake_last)} {rng.randint(1000,9999)}"
if name not in used:
used.add(name)
break
rows.append((name, rng.choice(["US", "GB", "FR", "DE", "IR", "RU"]), "OFAC-like-list"))
return rows
def main():
rng = random.Random(SEED)
now = datetime.now(timezone.utc)
t0 = time.monotonic()
accounts = gen_accounts(rng)
hot_accounts = [a for a in accounts if a["is_hot"]]
cold_accounts = [a for a in accounts if not a["is_hot"]]
role_pool = list(cold_accounts)
rng.shuffle(role_pool)
poison_accounts = role_pool[:N_POISON_ACCOUNTS]
sanctioned_accounts = role_pool[N_POISON_ACCOUNTS : N_POISON_ACCOUNTS + N_SANCTIONED_MATCHES]
clean_account = role_pool[N_POISON_ACCOUNTS + N_SANCTIONED_MATCHES]
cold_sample = role_pool[N_POISON_ACCOUNTS + N_SANCTIONED_MATCHES + 1 : N_POISON_ACCOUNTS + N_SANCTIONED_MATCHES + 61]
poison_account_ids = [a["id"] for a in poison_accounts]
sanctioned_account_ids = [a["id"] for a in sanctioned_accounts]
hot_account_ids = [a["id"] for a in hot_accounts]
cold_account_id_sample = [a["id"] for a in cold_sample]
clean_account_id = clean_account["id"]
categories = gen_categories(rng)
normal_txs = gen_normal_transactions(rng, accounts, now)
malformed_txs, poison_tx_ids = gen_malformed_transactions(rng, accounts, set(poison_account_ids), now)
all_txs = normal_txs + malformed_txs
rng.shuffle(all_txs)
sanctions_entries = gen_sanctions_entries(rng, [a["holder_name"] for a in sanctioned_accounts])
print(f"generated {len(accounts)} accounts, {len(all_txs)} transactions, "
f"{len(categories)} categories, {len(sanctions_entries)} sanctions entries "
f"in {time.monotonic() - t0:.1f}s (SEED={SEED})")
with psycopg.connect(DSN, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"TRUNCATE accounts, merchant_categories, transactions, sanctions_entries, "
"notifications, audit_events RESTART IDENTITY"
)
with cur.copy(
"COPY accounts (id, holder_name, currency, balance_minor, is_hot, created_at) FROM STDIN"
) as copy:
for a in accounts:
copy.write_row((a["id"], a["holder_name"], a["currency"], a["balance_minor"], a["is_hot"], a["created_at"]))
with cur.copy(
"COPY merchant_categories (id, code, label, risk_weight) FROM STDIN"
) as copy:
for c in categories:
copy.write_row((c["id"], c["code"], c["label"], c["risk_weight"]))
with cur.copy(
"COPY transactions (id, account_id, kind, amount_minor, currency, merchant_name, "
"category_id, status, risk_score, created_at) FROM STDIN"
) as copy:
for row in all_txs:
copy.write_row(row)
with cur.copy(
"COPY sanctions_entries (normalized_name, country, list_source) FROM STDIN"
) as copy:
for row in sanctions_entries:
copy.write_row(row)
cur.execute("ANALYZE accounts, transactions, merchant_categories, sanctions_entries")
manifest = {
"seed": SEED,
"generated_at": now.isoformat(),
"counts": {
"accounts": len(accounts),
"transactions": len(all_txs),
"merchant_categories": len(categories),
"sanctions_entries": len(sanctions_entries),
},
"hot_account_ids": hot_account_ids,
"poison_account_ids": poison_account_ids,
"sanctioned_account_ids": sanctioned_account_ids,
"clean_account_id": clean_account_id,
"cold_account_ids_sample": cold_account_id_sample,
"malformed_row_count": len(malformed_txs),
}
cur.execute(
"INSERT INTO seed_manifest (id, data) VALUES (1, %s::jsonb) "
"ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data",
(json.dumps(manifest),),
)
conn.commit()
if poison_tx_ids:
import redis as redis_sync
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
r = redis_sync.from_url(redis_url, decode_responses=True)
r.delete("poison:tx_by_account")
r.hset("poison:tx_by_account", mapping=poison_tx_ids)
elapsed = time.monotonic() - t0
print(f"seed complete in {elapsed:.1f}s")
print("row counts:")
for table, count in manifest["counts"].items():
print(f" {table}: {count}")
print(f"hot account ids: {hot_account_ids}")
print(f"poison account ids: {poison_account_ids[:5]}... ({len(poison_account_ids)} total)")
print(f"malformed rows: {manifest['malformed_row_count']}")
print(f"clean_account_id: {clean_account_id}")
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""One transaction, one insights call, assert 200s -- phase-0-base-project-spec.md §12."""
import json
import os
import sys
import urllib.request
import psycopg
GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://localhost:8000")
DSN = os.environ.get("POSTGRES_DSN", "postgresql://ledgerline:ledgerline@localhost:5432/ledgerline")
def _get(url):
with urllib.request.urlopen(url, timeout=10) as resp:
return resp.status, json.loads(resp.read())
def _post(url, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read())
def main():
with psycopg.connect(DSN, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SELECT data FROM seed_manifest LIMIT 1")
row = cur.fetchone()
if not row:
print("smoke: no seed manifest found -- run `make seed` first", file=sys.stderr)
sys.exit(1)
account_id = row[0]["clean_account_id"]
status, body = _post(
f"{GATEWAY_URL}/api/transactions",
{
"account_id": account_id,
"kind": "deposit",
"amount_minor": 500,
"currency": "EUR",
"merchant_name": "Smoke Test Merchant",
},
)
assert status == 201, f"transaction failed: {status} {body}"
print(f"transaction ok: {body['transaction_id']} status={body['status']}")
status, body = _get(f"{GATEWAY_URL}/api/accounts/{account_id}/insights")
assert status == 200, f"insights failed: {status} {body}"
print(f"insights ok: {body}")
print("smoke test passed")
if __name__ == "__main__":
main()