Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
120 lines
5.5 KiB
Python
120 lines
5.5 KiB
Python
"""Case study: Banking / transactions.
|
|
|
|
Architectural pattern demonstrated: **cross-object balance invariant**.
|
|
A transaction's validity depends on the sender account's current balance --
|
|
the validator must read related state (the sender account) and compare to the
|
|
proposed change. This pattern doesn't fit single-table CHECK constraints; it
|
|
requires a runtime validator that reads cross-object state at write time.
|
|
|
|
Object types: account, transaction, audit_log
|
|
Key invariants:
|
|
- sender_account.balance >= amount before transaction commits
|
|
- account ownership: only the account's owner can initiate transfers
|
|
- transactions are immutable after creation (no UPDATE)
|
|
- balance updates are reactions, not direct writes (single source of truth)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pedo.core.models import (
|
|
AccessContext, DataObject, ObjectType, Operation,
|
|
PermissionRule, PrivilegeType, ReactionDeclaration,
|
|
Relationship, RelationshipAction,
|
|
)
|
|
from pedo.core.store import ObjectStore
|
|
|
|
|
|
def validate_transaction_balance(proposed, existing, accessor, store):
|
|
"""Sender account must have sufficient balance for the transfer."""
|
|
if existing is not None:
|
|
return "Transactions are immutable; cannot UPDATE"
|
|
sender_id = proposed.content.get("sender_account_id")
|
|
amount = proposed.content.get("amount", 0)
|
|
if sender_id is None or amount is None:
|
|
return "sender_account_id and amount are required"
|
|
if amount <= 0:
|
|
return f"Transfer amount must be positive, got {amount}"
|
|
sender = store.raw_read(sender_id)
|
|
if sender is None:
|
|
return f"Sender account {sender_id} not found"
|
|
if sender.type_name != "account":
|
|
return f"sender_account_id must reference an account, not {sender.type_name}"
|
|
if sender.content.get("balance", 0) < amount:
|
|
return (f"Insufficient balance: account has "
|
|
f"{sender.content.get('balance', 0)}, transfer requires {amount}")
|
|
return True
|
|
|
|
|
|
def validate_transaction_recipient_exists(proposed, existing, accessor, store):
|
|
"""Recipient account must exist and accept transfers."""
|
|
if existing is not None:
|
|
return True
|
|
recipient_id = proposed.content.get("recipient_account_id")
|
|
if not recipient_id:
|
|
return "recipient_account_id is required"
|
|
recipient = store.raw_read(recipient_id)
|
|
if recipient is None or recipient.type_name != "account":
|
|
return f"Recipient account {recipient_id} not found"
|
|
if recipient.content.get("status") == "frozen":
|
|
return "Recipient account is frozen"
|
|
return True
|
|
|
|
|
|
def apply_transaction(event, store):
|
|
"""Reaction: when a transaction commits, debit sender and credit recipient.
|
|
The handler did not have to encode this -- the schema declares it."""
|
|
sys_ctx = AccessContext(user_id="system", role="system", org_id=event["object_org"])
|
|
sender_id = event["object_content"]["sender_account_id"]
|
|
recipient_id = event["object_content"]["recipient_account_id"]
|
|
amount = event["object_content"]["amount"]
|
|
|
|
sender = store.raw_read(sender_id)
|
|
if sender is not None:
|
|
store.update(sender_id,
|
|
{"balance": sender.content.get("balance", 0) - amount},
|
|
sys_ctx, _reaction_depth=event["depth"])
|
|
recipient = store.raw_read(recipient_id)
|
|
if recipient is not None:
|
|
store.update(recipient_id,
|
|
{"balance": recipient.content.get("balance", 0) + amount},
|
|
sys_ctx, _reaction_depth=event["depth"])
|
|
|
|
|
|
def register_banking_types(store: ObjectStore) -> None:
|
|
store.register_reaction_handler("apply_transaction", apply_transaction)
|
|
|
|
# Account: owner-only management; balance is updated only by reactions.
|
|
store.register_type(ObjectType(
|
|
name="account",
|
|
fields={"holder_name": "str", "balance": "int", "status": "str"},
|
|
permission_rules=[
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "auditor"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {"role": "admin"}),
|
|
# Balance writes happen via the apply_transaction reaction (system role)
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "system"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.WRITE, {"role": "admin"}),
|
|
],
|
|
default_policy=Operation.DENY,
|
|
))
|
|
|
|
# Transaction: immutable after create; validators run cross-object checks.
|
|
store.register_type(ObjectType(
|
|
name="transaction",
|
|
fields={"sender_account_id": "str", "recipient_account_id": "str",
|
|
"amount": "int", "subject": "str"},
|
|
permission_rules=[
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"is_owner": True}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "auditor"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.READ, {"role": "system"}),
|
|
PermissionRule(Operation.ACCEPT, PrivilegeType.INSERT, {}), # any authenticated user
|
|
# No WRITE / UPDATE / DELETE rules: transactions are immutable.
|
|
],
|
|
validators=[validate_transaction_balance,
|
|
validate_transaction_recipient_exists],
|
|
reactions=[
|
|
ReactionDeclaration(event="after_create", handler="apply_transaction"),
|
|
],
|
|
default_policy=Operation.DENY,
|
|
))
|