Two long-standing correctness gaps, both flagged in fin-research-frontier. A1 — POST /api/transactions could write half a linked pair TransactionCreate inherited the full six-value type Literal and accepted a free-standing linked_account_id, so any client could create a lone `transfer` row with no partner. That is exactly the balance corruption invariant #1 exists to prevent: the orphan counts toward SUM(amount - fee) and paired-delete can never find a partner to remove it with. TransactionCreate now narrows type to the single-leg kinds and rejects a caller-supplied linked_account_id. TransactionBase stays wide on purpose — reads must keep describing historical rows of every type. splitwise_pay deliberately stays allowed. The two-leg creator uses that type, but a standalone row carrying splitwise_partner_share is also a legitimate record, and is what `fin tx add --type splitwise_pay --partner-share` writes. Narrowing to expense/income would have broken it. B3 — no idempotency, so any retry double-booked Migration i9d0e1f2a3b4 adds a nullable client_ref to transactions. POST /api/transactions, /batch, /transfer and /settlement accept it, and crud.find_by_client_ref is checked before writing, so retrying a timed-out request replays the original rows instead of booking them again. NULL means "no idempotency requested" and never collides. Deviation from the frontier's sketch, which called for unique-when-not-null: the index is NOT unique. A transfer writes two rows and a batch writes many, all under one key, so uniqueness cannot live on the column. Suppression is a check-then-write inside the creators instead — sound here because SQLite serialises writers and the scheduler never writes transactions. Exposed on the MCP create_transaction/create_transfer/create_settlement tools, since a retrying agent is the case B3 was written about. The server instructions no longer tell agents that writes are non-idempotent. Tests: 60 passing, up from 47. Thirteen new cover retries collapsing to one write across all four endpoints, differing keys staying separate, NULL keys not colliding, and the orphan-leg guard refusing transfer/settlement/manual linkage while the four legitimate single-leg types still work. Migration verified both directions on a scratch DB seeded at the previous head: existing rows survive with NULL client_ref, index is created, and downgrade drops cleanly with rows intact. Verified live over HTTP that a transfer submitted three times with one client_ref yields one pair of legs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
38 lines
1.3 KiB
Python
38 lines
1.3 KiB
Python
"""add client_ref idempotency key to transactions
|
|
|
|
Revision ID: i9d0e1f2a3b4
|
|
Revises: h8c9d0e1f2a3
|
|
Create Date: 2026-08-22
|
|
|
|
Adds a nullable, caller-supplied idempotency key. Every row written by one
|
|
request carries the same value, so a retry after an ambiguous failure returns
|
|
the existing rows instead of booking them twice.
|
|
|
|
The index is deliberately NOT unique: a transfer writes two legs and a batch
|
|
writes many, all sharing one key. Duplicate suppression happens as a
|
|
check-then-write inside the crud creators. Backward-safe — the column is
|
|
nullable and every existing row keeps NULL, which the creators treat as
|
|
"no idempotency requested".
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision: str = "i9d0e1f2a3b4"
|
|
down_revision: Union[str, None] = "h8c9d0e1f2a3"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
with op.batch_alter_table("transactions") as batch_op:
|
|
batch_op.add_column(sa.Column("client_ref", sa.Text(), nullable=True))
|
|
op.create_index("ix_transactions_client_ref", "transactions", ["client_ref"])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index("ix_transactions_client_ref", table_name="transactions")
|
|
with op.batch_alter_table("transactions") as batch_op:
|
|
batch_op.drop_column("client_ref")
|