DEV Community

kongkong
kongkong

Posted on

Put a Ledger Between the Model and the Row

A billing operator approved an AI-suggested pause on sub_9f21. The UI returned 200. Finance later found the subscription already paused from a browser retry ten seconds earlier. Slack blamed “the model.” The second request never called a model. Generation and mutation had shared one POST, so a normal retry became a second write.

Cheap completion APIs make that mash-up feel like a feature. A handler that loads a row, asks a vendor, and updates the same row in one shot looks tidy in a screenshot. It is also how you lose the ability to say whether a 409 was a stale snapshot, a permission miss, or a double spend. Intelligence is not a GRANT. The missing piece is a ledger the apply path can spend without thinking.

Advice is not a transaction

I treat every model-backed mutation as two products that happen to share a domain object:

  1. A suggestion is a durable record of what the caller was allowed to see, what the model proposed, and which row version that proposal was built on.
  2. A commit is a separate permissioned action that spends that record against the current row, once.

If those two live in one function, you cannot replay the advice, you cannot expire it, and you cannot keep retries from inventing a second story. The model never belongs in the write transaction. Consultants do not get the checkbook.

This is not a prompt-quality essay. It is a rehearsal you can paste into a small FastAPI app: subscriptions instead of a chat demo, a suggestion table that cannot update billing state, and an apply route that is forbidden from calling a provider.

Schema first, because the prompt will not stop a write

Ship the ledger in a migration that has no UPDATE on subscriptions. If the generated handler “needs a column to write,” that is the smell. Two deploys is cheaper than a week of explaining extra version bumps.

-- 001_subscription_suggestions.sql
CREATE TABLE subscription_suggestions (
  suggestion_id TEXT PRIMARY KEY,
  subscription_id TEXT NOT NULL,
  actor_id TEXT NOT NULL,
  snapshot_json TEXT NOT NULL,
  change_json TEXT NOT NULL,
  seen_version INTEGER NOT NULL,
  provider TEXT NOT NULL,
  lifecycle TEXT NOT NULL,
  created_at TEXT NOT NULL
);
-- This file must not mutate subscriptions.
Enter fullscreen mode Exit fullscreen mode

The snapshot is the lock, not a timestamp the model wrote in a paragraph. When people argue that “the model was wrong,” they are usually arguing about stale context. If you cannot reload the exact read, you cannot blame the provider and you cannot defend the commit.

Typed records, no optional write smuggled in

Keep the contract small. Optional fields are how apply sneaks into a suggest payload “for later.”

# suggestion_contract.py
from typing import Literal, Optional
from pydantic import BaseModel, Field

class SubscriptionView(BaseModel):
    id: str
    state: Literal["active", "paused", "canceled", "past_due"]
    plan_code: str
    version: int
    last_invoice_cents: int
    account_note: Optional[str] = None

class SuggestedChange(BaseModel):
    change: Literal["pause", "resume", "cancel", "no_op"]
    rationale: str = Field(min_length=8, max_length=500)
    seen_version: int

class SuggestionRecord(BaseModel):
    suggestion_id: str
    subscription_id: str
    actor_id: str
    snapshot: SubscriptionView
    change: SuggestedChange
    provider: str
    lifecycle: Literal["draft", "spent", "rejected", "expired"]
Enter fullscreen mode Exit fullscreen mode

seen_version is copied from the view the provider received. If the wrapper invents a version, fail the suggestion. Do not “helpfully” apply prose to the wrong generation of the row.

Suggest is a query-shaped POST

Creating a suggestion authenticates, authorizes a read, snapshots the subscription, calls a seam, and inserts a ledger row. It does not flip state, even when the recommendation looks obvious. If that feels slow for a demo, that slowness is the rehearsal.

# suggest.py
from fastapi import APIRouter, Depends, HTTPException
from .auth import require_user
from .subscriptions import SubscriptionRepo, Forbidden, NotFound
from .suggestions import SuggestionRepo, new_id
from .advisor import Advisor

router = APIRouter()

@router.post("/subscriptions/{subscription_id}/suggestions")
def create_suggestion(subscription_id: str, user=Depends(require_user)):
    try:
        snapshot = SubscriptionRepo.get_for_read(subscription_id, actor_id=user.id)
    except NotFound:
        raise HTTPException(status_code=404, detail="subscription not found")
    except Forbidden:
        raise HTTPException(status_code=403, detail="read not allowed")

    change = Advisor.recommend(snapshot)
    if change.seen_version != snapshot.version:
        raise HTTPException(
            status_code=409,
            detail="advisor echoed a version it was not given",
        )

    return SuggestionRepo.insert(
        suggestion_id=new_id(),
        subscription_id=subscription_id,
        actor_id=user.id,
        snapshot=snapshot,
        change=change,
        provider=Advisor.name,
        lifecycle="draft",
    )
Enter fullscreen mode Exit fullscreen mode

The 409 on a mismatched seen_version is the first place a sloppy SDK wrapper will lie. I would rather fail the draft than let confident JSON land on the wrong generation. Can your current logs tell that failure apart from “row changed under us” on apply?

The advisor may not see a repository

People paste a vendor client into a module that already imported the ORM, then call the paste “architecture.” The seam takes a SubscriptionView and returns a SuggestedChange. No request object, no session, no bearer token in a prompt log you will one day export.

# advisor.py
class Advisor:
    name = "env-complete"

    @staticmethod
    def recommend(snapshot: SubscriptionView) -> SuggestedChange:
        # Keep SubscriptionRepo out of this file on purpose.
        return client_from_env().complete(
            system=(
                "Return JSON for SuggestedChange. "
                "Copy seen_version from the snapshot. Never invent a version."
            ),
            user=snapshot.model_dump_json(),
            schema=SuggestedChange.model_json_schema(),
        )
Enter fullscreen mode Exit fullscreen mode

I am not naming a model. The product is not the incident, and the incident is whether complete() can change billing state. In this module it cannot, because there is no repository. If you cannot keep the repository out, you are not ready to argue about latency or which free tier you used.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I treat MonkeyCode's free model access and free server option as a throwaway box for hosting this suggestion API and its test runner, not as a substitute for production permission checks. A disposable box is enough to feel 401, 403, and 409 before they touch a live subscription.

Prove the ledger cannot move the row

Do not assert that the rationale sounds wise. Assert that the slice fails closed, that a retry does not become a commit, and that the subscription version is unchanged after suggest. Run these against the process you actually booted, including auth middleware. A notebook that imported the route module and skipped middleware is not a rehearsal.

# test_suggestion_ledger.py
def test_suggest_without_credentials_is_401(client):
    res = client.post("/subscriptions/sub_9f21/suggestions")
    assert res.status_code == 401

def test_suggest_without_read_scope_is_403(client, observer_token):
    res = client.post(
        "/subscriptions/sub_9f21/suggestions",
        headers=auth(observer_token),
    )
    assert res.status_code == 403

def test_draft_does_not_bump_subscription_version(client, owner_token, sub_9f21):
    before = sub_9f21.version
    res = client.post(
        "/subscriptions/sub_9f21/suggestions",
        headers=auth(owner_token),
    )
    assert res.status_code == 200
    assert res.json()["lifecycle"] == "draft"
    assert sub_9f21.reload().version == before

def test_idempotent_create_returns_the_same_suggestion_id(client, owner_token, idem_key):
    headers = auth(owner_token, idem_key)
    first = client.post("/subscriptions/sub_9f21/suggestions", headers=headers)
    second = client.post("/subscriptions/sub_9f21/suggestions", headers=headers)
    assert first.status_code == 200
    assert second.status_code == 200
    assert first.json()["suggestion_id"] == second.json()["suggestion_id"]
Enter fullscreen mode Exit fullscreen mode

If you keep one assertion, keep the version check. A suggest endpoint that increments the subscription version is a write wearing a read costume, and it will poison apply with conflicts you invented. Idempotency belongs on create because browsers retry. A new draft on every retry is how one subscription collects three incompatible stories.

export BASE=http://127.0.0.1:8000

curl -s -o /tmp/unauth.json -w "unauth:%{http_code}\n" \
  -X POST "$BASE/subscriptions/sub_9f21/suggestions"

curl -s -o /tmp/forbidden.json -w "observer:%{http_code}\n" \
  -H "Authorization: Bearer $OBSERVER" \
  -X POST "$BASE/subscriptions/sub_9f21/suggestions"

curl -s -D - -o /tmp/draft.json \
  -H "Authorization: Bearer $OWNER" \
  -H "Idempotency-Key: rehearsal-7c2e" \
  -X POST "$BASE/subscriptions/sub_9f21/suggestions"

python -c "import json; print(json.load(open('/tmp/draft.json'))['lifecycle'])"
# draft — SELECT on subscriptions.version must match the value from before the POST
Enter fullscreen mode Exit fullscreen mode

Commit spends the ledger; it does not think

Only after those checks stay green do I add apply, as a different resource with a different permission. The handler loads the suggestion, checks write scope, refuses anything that is not draft, compares versions, and updates one row in one transaction. It does not call the advisor again. A retry must not become a different change. Calling the model during apply is how a pause becomes a cancel while the network is merely being honest.

# commit.py
@router.post("/suggestions/{suggestion_id}/commit")
def commit_suggestion(suggestion_id: str, user=Depends(require_user)):
    record = SuggestionRepo.get(suggestion_id)
    if record is None:
        raise HTTPException(status_code=404, detail="suggestion not found")
    if not SubscriptionRepo.can_write(record.subscription_id, actor_id=user.id):
        raise HTTPException(status_code=403, detail="write not allowed")
    if record.lifecycle != "draft":
        raise HTTPException(
            status_code=409,
            detail=f"suggestion already {record.lifecycle}",
        )

    try:
        SubscriptionRepo.apply_change(
            subscription_id=record.subscription_id,
            change=record.change.change,
            expected_version=record.change.seen_version,
            actor_id=user.id,
        )
    except Conflict:
        raise HTTPException(
            status_code=409,
            detail="subscription changed since this draft was stored",
        )

    SuggestionRepo.mark_spent(suggestion_id)
    return {"ok": True, "suggestion_id": suggestion_id}
Enter fullscreen mode Exit fullscreen mode

Think of the draft as a single-use voucher. Spending it twice is a 409, not a second generation. Streaming tokens from the commit route to make the button feel alive is how you hide a write behind a progress bar.

Operational rules that are not footnotes

  • Persist the suggestion in the same system that owns the subscription, or you will commit a plan whose snapshot has already vanished.
  • Expire drafts when version moves, or operators will apply archaeology with a straight face.
  • Write suggestion_id onto the billing event. An audit trail that is a chat screenshot in a ticket is not an audit trail.
  • Keep suggest and commit in separate deploys if that is what it takes to keep the first migration write-free.

Skip this pattern if the model only writes copy that never touches a row. A ledger would be ceremony. Skip it if the domain has no conflict token and no permission bit; you have a larger problem than a wrapper. If you need a multiplayer sketch this afternoon, keep the API unable to mutate until the contract exists in code. This is for teams who already know the feature will write and do not want to discover authorization in production.

I still hear “which model is smart enough to hold a write lock?” as if fluency were a database privilege. That question keeps producing one handler that does everything badly. Trust lives in the handoff: the read check, the snapshot you stored, the version you compared, and the commit that is not allowed to think. Generated code made that handler easy. It did not make rollback cheap, and it did not make a silent 200 that mutated twice acceptable.

Reuse the path before anyone debates a prompt. Boot the API with a real database and an advisor that cannot import SubscriptionRepo. Create one actor who can read sub_9f21 and one who cannot. Hit POST /subscriptions/{id}/suggestions until 401, 403, 404, and 200 are boring. Prove the version did not move, replay the idempotency key, then enable commit against a stale draft that must return 409 without calling the model.

Which boundary in your stack is least stable on retry right now—the read, the ledger insert, or the spend—and what status code does it actually return? Send a concrete 403, a 409, or a 200 that wrote twice. I care less about the wording of the rationale than about whether the advice can survive being wrong without taking the row with it.

Top comments (0)