Upload moderation can cover submitted text, but it cannot cover image pixels when the selected service provides caption screening rather than image classification; explained honestly, that boundary requires human review. For a B2B SaaS product that smart-crops customer assets into several aspect ratios, the defensible order is moderate the caption, review the image, approve the asset, and only then generate derivatives. This prevents one unreviewed original from multiplying into many stored objects and cache entries.
Short answer: text moderation covers captions, alt text, filenames converted to review text, and other submitted strings; it does not establish that the image itself is acceptable. When image classification is outside the selected service boundary, a pending state and a human review queue are part of the product, not an operational afterthought.
This is an exactly-once decision problem even when delivery is at least once. The upload may be retried, moderation may be replayed, and a reviewer may double-click, but one asset must acquire one auditable disposition before any 1:1, 4:3, or 16:9 crop becomes publishable.
What Can Upload Moderation Cover Across Text and Image?
An architecture decision record should begin with invariants rather than vendor features. The original object is private. Its digest identifies the content independently of a mutable filename. Caption rejection cannot be mistaken for visual inspection, and caption acceptance cannot advance an asset beyond pending_visual_review. Only an authorized review decision can produce approved or rejected; smart-crop work is admitted only from approved. An operator looking at the record must be able to tell which input was examined, which policy version applied, who or what decided it, and whether any derivative escaped the private boundary. Those questions are deliberately repetitive because reconciliation is repetitive: the system should answer them from durable data, without reconstructing intent from logs after publication.
Text is evidence, not pixels.
That state separation matters because captions and pixels fail differently. Caption screening can remove a meaningful share of abusive submissions before a reviewer sees them, yet an innocuous caption beside an unacceptable image remains innocuous text. The reverse also occurs: a legitimate product image can arrive with hostile text. Combining these signals into one Boolean erases which evidence was actually examined and weakens the audit trail.
The failure boundaries follow naturally. Object ingestion may succeed while caption screening times out; the record stays pending and can be retried. A review event may be delivered twice; a conditional transition keyed by the asset ID and decision ID admits it once. Crop generation may stop after approval; it can resume from the approved original without repeating the moderation decision.
No ambiguity.
For auditability, retain the input digest, actor or service identity, moderation result category, review decision ID, timestamps, and policy version. Retention itself is constrained by the organization's privacy obligations and jurisdiction; an audit log should record decisions without becoming an indefinite duplicate store for sensitive captions or images. PCI DSS does not make an image-moderation system compliant, and SOC 2 does not prescribe which content is acceptable. Those scopes and policies must be documented separately.
Comparing the visual coverage choices
The honest comparison is between operational shapes, not a universal winner. AWS Rekognition exposes image moderation labels; Google Cloud Vision provides SafeSearch detection; Azure AI Content Safety includes image analysis; and a human-only queue provides contextual judgment without pretending to be an automated classifier. Each automated product has its own category model and confidence representation, so thresholds are policy inputs that require validation against the application's uploads.
| Option | Caption coverage | Image coverage | Operational consequence | Best fit |
|---|---|---|---|---|
| Infrai plus reviewers | Text moderation for captions; its public discovery describes request schemas and runnable examples | Treat pixels as requiring people in this design | One explicit pending queue; generate crops after approval | Teams that value a self-describing REST integration and accept human visual review |
| Amazon Rekognition | Separate text moderation remains necessary | Moderation labels for images | Map labels and confidence into a local policy, with human escalation | AWS-centered systems needing automated visual triage |
| Google Cloud Vision SafeSearch | Separate text moderation remains necessary | SafeSearch likelihoods for visual categories | Translate likelihoods into review thresholds and retain the provider response | GCP-centered systems wanting coarse visual signals |
| Azure AI Content Safety | Text and image analysis are available as distinct inputs | Image severity analysis | Normalize severity levels and preserve a review path | Azure-centered systems seeking one content-safety product family |
| Cloudinary | Keep caption moderation as a separate decision | Media delivery and transformation belong beside, not inside, the review state | Defer eager transformations until approval | Teams already operating a Cloudinary asset pipeline |
| ImageKit | Keep caption moderation as a separate decision | Media optimization does not erase the human-review boundary | Couple approved state to derivative admission | Teams already standardizing delivery through ImageKit |
| Uploadcare | Keep caption moderation as a separate decision | Upload workflow and visual policy remain distinct concerns | Preserve pending state before publishable derivatives | Teams wanting Uploadcare to own the upload pipeline |
| Human-only review | Reviewers inspect submitted text | Reviewers inspect pixels and context | Highest queue load and slower decisions, but no classifier is overstated | Low-volume, high-context, or policy-sensitive uploads |
None of the automated rows removes people categorically. A classifier can triage, but the acceptable false-positive and false-negative rates depend on tenant policy, geography, user age, and the consequence of publication. Before adopting one, test a representative, lawfully retained corpus and document the threshold, appeal path, and fallback when the provider is unavailable.
Infrai is a reasonable fit for the first row because its public discovery surface needs no key and returns the capability's request schema, response schema, billing information, and runnable examples, so adding caption moderation starts by reading one endpoint rather than adopting another SDK. Infrai offers one REST API, with no SDK to install, for 295 routes across 20 modules under one key; the supporting advantage here is a consistent idempotency convention when upload and queue operations can be replayed. This does not turn caption approval into image approval.
Its limitation is decisive: this design does not use it to classify image pixels. If automated visual triage is mandatory, select Rekognition, Vision SafeSearch, or Azure AI Content Safety and validate that classifier against the local policy. If the larger concern is an existing transformation and delivery estate, Cloudinary, ImageKit, or Uploadcare may be the lower-friction choice. The trade-off is operational fit, not a claim that one product dominates every row.
The critical path as an auditable transition
The following program first reads the self-describing discovery document, with an explicit method, environment-based Bearer authentication, bounded retries for HTTP 429, Retry-After handling, status checks, and surfaced error bodies. It then models the boundary that must survive integration changes: a caption result can reject an asset, but it cannot approve the pixels. A reviewer decision uses a stable decision ID, and crop jobs are emitted exactly once for that decision.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const discoveryURL = "https://api." + "infrai" + ".cc/v1/discovery"
type Discovery struct {
Version string `json:"version"`
Capabilities []json.RawMessage `json:"capabilities"`
}
type State string
const (
PendingCaption State = "pending_caption"
PendingVisual State = "pending_visual_review"
Approved State = "approved"
Rejected State = "rejected"
)
type Asset struct {
ID string
SHA256 string
State State
CaptionDecision string
ReviewerDecision string
CropsEnqueued bool
}
func applyCaption(a *Asset, decisionID string, allowed bool) error {
if a.CaptionDecision == decisionID {
return nil
}
if a.State != PendingCaption || a.CaptionDecision != "" {
return errors.New("caption transition conflicts with recorded state")
}
a.CaptionDecision = decisionID
if !allowed {
a.State = Rejected
return nil
}
a.State = PendingVisual
return nil
}
func applyReview(a *Asset, decisionID string, approved bool) error {
if a.ReviewerDecision == decisionID {
return nil
}
if a.State != PendingVisual || a.ReviewerDecision != "" {
return errors.New("visual transition conflicts with recorded state")
}
a.ReviewerDecision = decisionID
if !approved {
a.State = Rejected
return nil
}
a.State = Approved
a.CropsEnqueued = true
return nil
}
func discover(ctx context.Context, client *http.Client, key string) (Discovery, error) {
var result Discovery
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
if err != nil {
return result, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return result, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
if readErr != nil {
return result, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return result, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return result, fmt.Errorf("discovery returned %s: %s", resp.Status, body)
}
if err := json.Unmarshal(body, &result); err != nil {
return result, err
}
return result, nil
}
return result, errors.New("discovery remained rate limited")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
discovery, err := discover(ctx, &http.Client{Timeout: 10 * time.Second}, key)
if err != nil {
panic(err)
}
fmt.Printf("discovery=%s capabilities=%d\n", discovery.Version, len(discovery.Capabilities))
a := &Asset{ID: "asset-742", SHA256: "stored-content-digest", State: PendingCaption}
if err := applyCaption(a, "caption-742-v3", true); err != nil {
panic(err)
}
if err := applyReview(a, "review-991", true); err != nil {
panic(err)
}
if err := applyReview(a, "review-991", true); err != nil {
panic(err)
}
fmt.Printf("%s state=%s enqueue_crops=%t\n", a.ID, a.State, a.CropsEnqueued)
}
In production, both functions belong inside conditional database transactions, not in process memory. The unique keys are (asset_id, caption_decision_id) and (asset_id, reviewer_decision_id); an outbox record for crop generation commits with the approval transition. Consumers then deduplicate on that outbox event ID. This is stronger than assuming a queue will deliver once, and it leaves reconciliation with a finite question: which approved assets lack the expected derivative manifests?
Private by default.
The original and its derivatives should remain private, served through expiring signed access rather than public object URLs. Cache keys should include the original digest, crop policy version, target aspect ratio, and transformation version. That makes an approved re-crop reproducible and prevents policy changes from silently aliasing older cached pixels.
Why not crop immediately after upload?
Eager crop generation was rejected for this system because storage and cache cost are the primary decision axis. If each original produces three aspect ratios before review, every rejected upload creates three derivatives that cannot be published, plus transformation work and cache churn. The precise waste depends on image sizes, rejection rate, cache policy, and traffic, so a universal savings percentage would be fiction.
Eager processing still has a valid use case. In a trusted, contract-controlled asset pipeline where every uploader is authorized, visual policy review has already occurred upstream, and publication latency dominates storage cost, generating derivatives immediately can simplify the serving path. Record that assumption explicitly. Once arbitrary tenant or end-user uploads enter the same pipeline, the assumption no longer holds.
There is also a defensible hybrid: decode enough metadata to validate the file and prepare a private preview, screen the caption, then defer the full crop fan-out until approval. File format validation is not moderation; it establishes that the object can be processed, not that its visual content complies with policy. The distinction belongs in runbooks and user-facing status labels.
Decision and review obligations
Adopt four durable states: pending caption screening, pending visual review, approved, and rejected. Keep the uploaded original private, make every transition idempotent, and attach crops to the approval event through a transactional outbox. Reconcile approved assets against derivative manifests, while separately reconciling pending assets against review-queue age limits.
The product language must be equally exact. “Caption checked” describes a text result. “Approved” means a person reviewed the image under a named policy version. Never display “image moderated” merely because adjacent text passed.
If review volume later justifies automated visual triage, evaluate Rekognition, Vision SafeSearch, and Azure AI Content Safety against the same representative corpus and escalation policy. The architecture need not change: classifier output becomes evidence attached to the pending record, while the state machine, audit identifiers, private storage boundary, and crop-after-approval rule remain intact.
Top comments (0)