Generative UI buttons are not ordinary buttons. A double-click on "Regenerate" is not a harmless duplicate POST; it can launch two paid model jobs, consume two queue slots, and write two cost entries before the user sees the first spinner. In Hook Machine, PR #53 (fix/claim-paid-actions-server-side) came from exactly that class of bug: a fast repeated action could pay twice, and the cost ledger did not make the duplicate obvious enough.
The same sprint exposed a second issue that looks unrelated until you trace the user workflow. PR #57 (fix/render-inputs-snapshot) fixed regenerate/resume paths that dropped the selected voice and product identity lock. Together, these bugs define the real contract for interactive generative UX: paid actions need transaction safety, and long-lived editing sessions need state locks that survive retries, resumes, and partial regeneration.
Every paid generative click is a transaction. Treat model dispatches with financial-grade idempotency keys and server-owned locks.
The double-click billing race
The race is easy to create. A user clicks regenerate twice before the client disables the button. Two requests reach the server. If the server trusts the UI to prevent duplicates, both requests can claim the action, call the model provider, and append cost ledger entries. The UI eventually shows one winner, but the system paid for two attempts.
| Layer | Weak behavior | Correct behavior |
|---|---|---|
| Client | Disable button after first click and hope latency is low | Send a stable idempotency key with every paid action |
| Server | Start generation whenever a request arrives | Atomically claim the paid action before provider dispatch |
| Ledger | Record every provider call independently | Attach spend to one transaction ID and reject duplicates |
| UI resume | Hydrate defaults when fields are missing | Preserve voice, product, and render-input locks from the job snapshot |
This is the paid-action version of Pre-Spend Guards. Pre-spend validation stops bad inputs from spending. Idempotency stops repeated valid inputs from spending twice.
Client idempotency keys
The client should generate a deterministic action key from the stable parts of the request: job ID, action type, selected candidate, render inputs, and user intent. The key is not a security boundary. It is a coordination token that lets the server collapse duplicates into one in-flight operation.
import { createHash } from 'crypto';
type PaidAction = {
jobId: string;
action: 'regenerate' | 'promote' | 'render';
candidateId?: string;
renderInputsVersion: string;
userInstruction?: string;
};
function stableJson(value: unknown): string {
return JSON.stringify(value, Object.keys(value as Record<string, unknown>).sort());
}
export function getPaidActionKey(action: PaidAction): string {
return createHash('sha256')
.update(stableJson(action))
.digest('hex');
}
The key includes the render-input version. If the user changes the prompt, voice, or product lock, it is a new action. If they double-click the same regenerate command, it is the same action.
Server-side claim before spend
PR #53 moved the authoritative claim to the server. The server receives the idempotency key, attempts an atomic insert or lock, and only the winner is allowed to call the model provider. Duplicates return the in-flight job handle. This keeps the user experience responsive without pretending the browser is the source of truth.
type PaidActionClaim =
| { status: 'claimed'; transactionId: string }
| { status: 'duplicate'; transactionId: string; jobId: string };
async function claimPaidAction(
idempotencyKey: string,
jobId: string,
): Promise<PaidActionClaim> {
const existing = await paidActions.findByKey(idempotencyKey);
if (existing) {
return {
status: 'duplicate',
transactionId: existing.transactionId,
jobId: existing.jobId,
};
}
const transactionId = crypto.randomUUID();
await paidActions.create({
idempotencyKey,
transactionId,
jobId,
status: 'claimed',
});
return { status: 'claimed', transactionId };
}
Redis locks can speed this up, but the durable constraint belongs in the transaction store or database. If a process dies after provider dispatch but before ledger write, the recovery path still needs to know which transaction owned the spend.
Cost ledger reconciliation
The cost ledger fix was not just "do not add duplicate rows." The ledger needed to answer a stronger question: for this user-visible action, how many provider calls happened, which one became the surfaced result, and which transaction ID owns the spend? Without that shape, a duplicate can hide as a normal retry.
async function recordSpendOnce(
transactionId: string,
spend: { provider: string; amountUsd: number; providerJobId: string },
) {
const existing = await costLedger.findByTransaction(transactionId);
if (existing) return existing;
return costLedger.create({
transactionId,
provider: spend.provider,
amountUsd: spend.amountUsd,
providerJobId: spend.providerJobId,
recordedAt: new Date(),
});
}
That reconciliation model also helps with retries. If a provider call times out but later completes, the recovery job can attach the result to the same transaction instead of inventing a second billable action.
Render input snapshots and state locks
PR #57 fixed the other half of the session: a regenerate or resume could drop the selected voice and product identity lock. In AI video tools, those fields are not cosmetic. A voice model controls continuity across candidates. A product identity lock prevents the generator from drifting into a visually similar but wrong item. Losing either one changes the user's creative intent.
The fix was to snapshot render inputs as versioned state and make resume paths hydrate from that snapshot, not from UI defaults. This is the same principle as Drive-Backed Asset Catalogs: runtime state should be explicit, durable, and validated before use.
type RenderInputSnapshot = {
version: 2;
voiceModelId: string;
productIdentityLock: {
productId: string;
referenceImageIds: string[];
lockedAt: string;
};
promptContract: {
userInstruction: string;
negativePrompt?: string;
};
};
function hydrateRenderInputs(snapshot: RenderInputSnapshot): RenderInputSnapshot {
if (!snapshot.voiceModelId) {
throw new Error('Cannot resume render without locked voice model');
}
if (!snapshot.productIdentityLock?.productId) {
throw new Error('Cannot resume render without product identity lock');
}
return snapshot;
}
Audit and fix history
| PR / branch | Issue | Fix |
|---|---|---|
#53 fix/claim-paid-actions-server-side |
Double-clicking regenerate could pay twice while the ledger hid the duplicate | Server-owned paid-action claim, idempotency key, and transaction-scoped cost entries |
#57 fix/render-inputs-snapshot |
Regenerate and resume dropped selected voice and product identity locks | Versioned render-input snapshot hydrated by resume and retry paths |
| #17 presenter generation | Buttons looked dead and could bill twice in a neighboring presenter workflow | Server-declared pending state and stricter action ownership across buttons |
| #53 variant multiplier tests | Cost idempotency needed coverage in a sibling tool | Added tests around transient classifier and idempotent cost accounting |
What I'd do differently
I would start every paid generation endpoint with a transaction table before adding the first provider integration. It is tempting to add idempotency after the first duplicate spend bug because the UI can appear to guard clicks. That is backwards. Provider dispatch is the money boundary; the server needs ownership from day one.
I would also make state locks visible in the editor. If the tool is preserving a voice model and product identity, show those locks as first-class state. Users trust resume flows more when they can see what will be preserved before they click regenerate.
The pattern: financial-grade generative UX
Interactive AI tools need the ergonomics of creative software and the transaction safety of billing systems. The browser can make buttons feel responsive, but the server must own paid-action claims, ledger reconciliation, and render-input snapshots. That is how a tool can let users iterate quickly without silently charging twice or changing the creative identity underneath them.