Adding an AI image generator to a SaaS app that turns sales-call summaries into CRM actions creates an awkward boundary: the upload can change, a prompt preset can be edited, and a provider can accept work after the user has already clicked twice. The architectural choice follows from that constraint.
Short answer: store an immutable generation intent, reserve the tenant's budget before dispatch, execute through a narrow provider interface, and reconcile the final asset against the reservation before attaching it to a CRM action. Don't let the browser call an image service directly. This makes provider portability a consequence of the boundary, while idempotency and an audit trail protect the customer-facing workflow.
This is an architecture decision record, not a vendor ranking. The example takes a call transcript, derives a reviewed CRM action, and optionally generates a text-led follow-up card for that action. Speech recognition is upstream; an open-source system such as Whisper is one possible transcript source, but transcript provenance remains separate from image generation.
Decision record: make generation intent immutable
The system of record should capture what the user approved, not merely the request eventually sent to a model. A GenerationIntent therefore needs a tenant ID, CRM action ID, upload digest, preset version, normalized aspect ratio, prompt digest, budget reservation, and idempotency key. Once accepted, those fields don't change. A correction creates a new intent linked to the previous one.
That rule sounds strict because it is. If a sales representative changes “send renewal brief” to “schedule security review,” silently rewriting the prompt would leave the generated card detached from the action that authorized it. An append-only transition log gives reconciliation a stable question: which approved intent produced this asset?
Retries lie.
The failure boundaries are equally important. Upload validation ends before intent creation. Budget reservation and intent creation share one database transaction. Provider dispatch happens after commit. Asset attachment happens only after the output passes content-type, size, and policy checks. Each boundary can retry, but no retry is allowed to invent a second business operation.
| Option | Duplicate control | Audit quality | Provider portability | Appropriate use |
|---|---|---|---|---|
| Browser-to-provider request | Weak unless the provider contract is exposed to the client | Split across browser and provider logs | Low | Disposable prototypes with no tenant billing |
| Synchronous server request | One application key can suppress retries | Adequate until requests time out | Medium | Low-volume internal tools where the user can wait |
| Durable intent plus worker | Database uniqueness and explicit state transitions | Strong: approval, reservation, dispatch, and settlement are linked | High | Multi-tenant SaaS with CRM records and budget limits |
The durable path costs more operationally: it needs a queue or polling worker, transition storage, a sweeper for abandoned reservations, and an operator view. It is not suitable when every image is disposable and no customer-visible action, quota, or charge depends on the result. For a weekend prototype, stick with a synchronous server request and retain the same provider interface; add the ledger only when the business invariant exists.
What should a SaaS AI image generator upload, prompt preset, aspect ratio, and pricing guardrail record?
Record decisions at the point where they become irreversible. For an upload, keep the object reference, media type, byte count, and a cryptographic digest; don't copy arbitrary user bytes into the job row. For prompt presets, store a stable preset ID and version plus the rendered prompt digest. The editable template is useful for authoring, but the versioned rendering is what an auditor needs after the preset changes.
Aspect ratio belongs to application policy rather than provider syntax. Accept a small vocabulary such as 1:1, 4:3, and 16:9, then map it inside each adapter. This prevents a provider's size names from leaking into saved CRM actions. It also gives product and compliance teams one place to forbid shapes that don't fit an approved email or CRM surface.
Pricing guardrails should use a reserved-unit ledger even if the commercial price changes later. Before dispatch, the application calculates a conservative internal unit estimate from the chosen preset, ratio, and quality tier, then atomically checks the tenant limit and creates a reservation. After completion, settlement records the actual metered units supplied by the adapter. I'm not sure every provider exposes sufficiently detailed usage for exact reconciliation; resolve that during evaluation by inspecting a real success response and billing export. When it doesn't, settle against the documented request-class estimate and label that accounting basis explicitly.
Keep the original quote snapshot as evidence, but don't make mutable price tables part of the idempotency key. Two retries of one approved action are one logical generation even if a pricing configuration changed between attempts.
There is a compliance limit here. An audit trail can prove which input, policy version, and approval led to an output; it cannot prove that the output is truthful, licensed for every use, or free of sensitive material. Treat generated media as untrusted content. The OWASP guidance for LLM applications is broader than image generation, yet its emphasis on input handling, excessive agency, and downstream trust is a useful threat-model prompt. A human approval gate still belongs before an image becomes an external CRM communication.
How does one Go interface protect the image generator's critical path?
The adapter contract should speak in domain terms and return a provider-neutral receipt. It shouldn't expose model-specific size strings to handlers. The following executable example uses an in-memory store to make the transaction and duplicate behavior visible; production code should enforce the same uniqueness and balance checks in a database transaction.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
)
type Intent struct {
ID, TenantID, ActionID, PresetVersion string
PromptDigest, UploadDigest, Ratio string
ReservedUnits int64
}
type Receipt struct {
AssetRef string
UsedUnits int64
}
type ImageGenerator interface {
Generate(context.Context, Intent) (Receipt, error)
}
type Ledger struct {
mu sync.Mutex
remaining map[string]int64
intents map[string]Intent
receipts map[string]Receipt
}
var ErrBudgetExceeded = errors.New("tenant image budget exceeded")
func digest(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func (l *Ledger) Reserve(in Intent) (Intent, bool, error) {
l.mu.Lock()
defer l.mu.Unlock()
if existing, ok := l.intents[in.ID]; ok {
return existing, false, nil
}
if l.remaining[in.TenantID] < in.ReservedUnits {
return Intent{}, false, ErrBudgetExceeded
}
l.remaining[in.TenantID] -= in.ReservedUnits
l.intents[in.ID] = in
return in, true, nil
}
func (l *Ledger) Settle(intentID string, receipt Receipt) error {
l.mu.Lock()
defer l.mu.Unlock()
if old, ok := l.receipts[intentID]; ok {
if old != receipt {
return errors.New("conflicting settlement receipt")
}
return nil
}
in, ok := l.intents[intentID]
if !ok {
return errors.New("unknown generation intent")
}
if receipt.UsedUnits > in.ReservedUnits {
return errors.New("usage exceeds reservation")
}
l.remaining[in.TenantID] += in.ReservedUnits - receipt.UsedUnits
l.receipts[intentID] = receipt
return nil
}
type DemoGenerator struct{}
func (DemoGenerator) Generate(_ context.Context, in Intent) (Receipt, error) {
return Receipt{AssetRef: "objects/crm-action-4821/card.png", UsedUnits: 3}, nil
}
func main() {
ledger := &Ledger{
remaining: map[string]int64{"tenant-7": 10},
intents: map[string]Intent{},
receipts: map[string]Receipt{},
}
in := Intent{
ID: "tenant-7:crm-action-4821:v1", TenantID: "tenant-7",
ActionID: "crm-action-4821", PresetVersion: "follow-up-card@4",
PromptDigest: digest("Schedule a reviewed security follow-up"),
UploadDigest: digest("approved-logo-bytes"), Ratio: "4:3", ReservedUnits: 4,
}
reserved, _, err := ledger.Reserve(in)
if err != nil {
panic(err)
}
receipt, err := (DemoGenerator{}).Generate(context.Background(), reserved)
if err != nil {
panic(err)
}
if err := ledger.Settle(reserved.ID, receipt); err != nil {
panic(err)
}
fmt.Println(receipt.AssetRef)
}
The intent ID is the application idempotency key. A unique constraint on that key provides the decisive guarantee; queue delivery semantics do not. Exactly-once execution across a database, queue, and external generator isn't a realistic primitive, but exactly-once business effect is approachable when reservation, settlement, and attachment each reject duplicates independently.
Do not classify every error as retryable. Invalid ratios, rejected uploads, and exhausted budgets are terminal decisions. Network ambiguity is different: keep the same intent ID, query by the adapter's request token when its contract permits that, and otherwise retry under the same logical operation. Use stable application error classes in metrics and logs rather than copying provider messages into the CRM record.
Reconcile jobs before attaching CRM actions
The happy path is insufficient for a ledger-backed feature. Run a reconciler that scans reservations without terminal settlements, checks the provider-neutral receipt store, releases units for explicitly canceled work, and raises an operator-visible discrepancy when evidence conflicts. Never infer success only because an object exists: the object digest, intent ID, and expected media policy must agree. Observe four timestamps: accepted, dispatched, asset validated, and attached. Their differences distinguish queue delay from generation latency and CRM write delay without binding dashboards to a vendor. Count duplicate reservations rejected, terminal policy decisions, unsettled reservations by age, and settlement mismatches. Avoid transcript or rendered-prompt text in labels and logs; hashes and internal IDs are enough for correlation. Deployment needs the same caution — add the new intent fields before workers write them, deploy readers that tolerate both schema versions, and only then enable dispatch. Rotate adapters by routing a small policy-defined cohort, but compare outcomes through application measures such as validation pass rate and time to reviewed attachment. Visual quality remains a human judgment, so automated checks should claim less: correct type, bounded dimensions, digest match, tenant ownership, and completed policy review. Consider the concrete ambiguous case: a worker reserves four units for crm-action-4821, dispatches, and loses its process before recording the receipt. The sweeper must not release that reservation merely because the local worker disappeared. It first searches durable dispatch evidence, then obtains or reconstructs the provider-neutral receipt according to the adapter contract, validates the resulting object, settles once, and attaches only if the CRM action version still matches. If the action was superseded while generation ran, the asset can remain as audited output without becoming customer-visible. That distinction prevents “eventually completed” from turning into “silently sent.”
It's deliberate.
Test the invariants with concurrency, not just examples. Two goroutines reserving the same key must consume units once. Replaying a receipt must be a no-op, while a different receipt for the same intent must produce a conflict. A crash after reservation and before dispatch must leave recoverable evidence. A crash after generation and before attachment must not trigger another customer-visible CRM action.
Slow down here.
The costly failure is usually not an image request that fails cleanly; it is an ambiguous request that later produces an asset after the budget was released or the sales-call action was edited. The immutable link between action version, prompt digest, and reservation gives the reconciler enough evidence to decide instead of guessing.
Rejected option and the case where it wins
We rejected direct browser dispatch because it exposes provider policy to an untrusted client, fragments the audit trail, and makes tenant-level reservation difficult to enforce atomically. Signed uploads can still go directly to object storage, but their digests and ownership must be validated before an intent references them.
The catch is operational weight. A durable ledger and worker are poor fits for a single-user design toy with no CRM attachment, approval record, tenant quota, or financial consequence. In that case, a synchronous backend proxy with a request timeout, one idempotency key, and explicit upload limits is easier to reason about. Preserve the neutral interface and ratio vocabulary so migration remains an adapter change rather than a rewrite.
For the support workflow described here, the durable design earns its keep because provider portability is secondary to correctness: a generated follow-up card must correspond to one reviewed sales-call action, consume one reservation, and leave one inspectable chain of evidence. Providers can change. Those invariants cannot.
Top comments (0)