A phone can upload a sharp photo and still leave the product with nothing usable. The bytes are there. A thumbnail may exist. A background cutout may even look clean. None of that answers the question the product actually has to answer: can this image be shown, retried, reviewed, or published?
That is the failure I wanted to avoid. Vehicle listings are made out of media, yet accepting images is the easy part. The hard part is keeping the original, the expensive work, the human decision, and the publish button attached to the same object after retries and partial success.
This post is separate from the earlier provenance and approval-gate work. Those posts were about receipts and invalid final states. This one is about custody across storage, database jobs, vision workers, derived artifacts, human review, and publication. The model I use is simple: a media asset is a stateful object moving through a bounded vision pipeline. The endpoint bag is the failure mode.
The curated highlight for this build was Vehicle media intelligence pipeline with bounded multi-stage vision processing. Every expensive computer vision (CV) step has to write back to the same asset story, or the product will eventually show an image whose approval came from somewhere else.
1. Intake is proven by an event, not by a timestamp
The listing design starts with one walk-around becoming one finished listing: a hero set, social placements, description, and a spec sheet, with review before publication. Later work reserves room for 360 output and a forty-image set through SceneBundle. Product-shaped, yes. The important part sits underneath.
The trigger is a durable row in project_events named capture.media_secured. Its idempotency key starts with media-secured, includes the capture session identifier, and ends at version one. The payload is MediaSecuredEvent. It carries the project identifier, source asset identifiers, required roles satisfied, required roles missing, and processingEnqueued. Extra fields are rejected.
That last field is there because false is a real result. The phone may have secured the bytes while the next work item failed to enqueue. I wanted that outcome reported as a first-class condition, instead of being inferred later from silence.
The design also says the event row is the proof that photos are secured. The media_secured_at column alone is not enough, because another writer stamps it during egress. Two writers used the same timestamp for different meanings. The event has a name, a payload, and an idempotency key, so the capture handoff can be replayed or audited without guessing.
Only digest-verified source assets get their first processing_jobs rows. The first job key begins with preflight and includes the source asset identifier. That is the first custody boundary: work attaches to source asset identity, not to a temporary URL the interface happened to hold.
2. Separate endpoints break at the first partial retry
The tempting application programming interface (API) shape is easy to draw. Upload. Remove background. Enhance. Make thumbnail. Ask for status. Each endpoint has a clean request and a clean response, then the first uneven result arrives.
A derived image exists, while the quality verdict is missing. A retry produces a better frame, while the listing still points at the old one. A social crop finishes before the parent image is accepted. The review queue now contains outputs from different attempts of the same vehicle.
I did put bounded API surfaces around image work. The bulk per-image operations endpoint accepts image identifiers, optional transformation settings, and a priority hint. Its operation enum covers background removal, enhancement, and thumbnail creation. A separate status shape checks progress by vehicle identifier and can filter by job identifiers.
Those schemas are useful. They keep requests sane. They do not decide whether a listing is ready. Readiness lives in the persistent asset graph, because the last endpoint to run is often the wrong place to ask.
The listing build uses Postgres for that spine. It rejected an older five-queue description because the queues did not exist. The table is processing_jobs, with six check-constrained stages and states for queued, running, succeeded, failed, cancelled, and declined.
Workers claim rows with a SQL path that uses FOR UPDATE SKIP LOCKED. Stale work can be reclaimed after fifteen minutes. Attempts stop at three. The service role owns the claim path.
The drainer keeps the same shape. /api/cron/drain-processing runs from Vercel cron every five minutes. The default budget is ten, the maximum is fifty, and the route ceiling is one hundred twenty seconds. It is guarded by CRON_SECRET and PROJECT_PROCESSING_DRAIN_V1. Stage dispatch sits in STAGE_RUNNERS inside processing-drainer.ts.
That choice costs latency. A poller feels slower than a push system. I accepted that cost because the expensive part is keeping media, review, and publishability in agreement after a retry.
3. Four lanes have to agree
I draw the system as four lanes because each lane can tell a partial truth. Storage can prove bytes exist. The database can prove work was requested. A worker can say it produced something. The product can show a button as ready. The useful rule is the binding between them.
flowchart TD
subgraph storageLane
sourceBytes[Signed source bytes]
derivedBytes[Derived media bytes]
end
subgraph databaseLane
securedEvent[capture.media_secured event]
jobRows[processing_jobs rows]
reviewDecision[append-only review decision]
end
subgraph workerLane
preflight[matte preflight]
renderStage[studio render]
listingStage[listing generation]
marketingStage[marketing generation]
end
subgraph productLane
ownerReadiness[owner visible readiness]
reviewQueue[human review queue]
publicationGate[publication gate]
end
sourceBytes --> securedEvent
securedEvent --> jobRows
jobRows --> preflight
preflight --> renderStage
renderStage --> listingStage
listingStage --> marketingStage
marketingStage --> derivedBytes
derivedBytes --> reviewDecision
reviewDecision --> reviewQueue
reviewQueue --> publicationGate
jobRows --> ownerReadiness```
The storage lane begins with signed phone evidence. In `src/project/services/vehicle-project-orchestrator.ts`, the orchestrator keeps digest verification narrow. Digest verification proves transport: the bytes that left the phone are the bytes in storage. It does not prove the image depicts a car. The field named `contentState` can say `not_evaluated`, pending, verified, or refused. It must never translate a digest match into content approval.
The same orchestrator signs URLs in parallel and returns them beside the asset states. That comment matters. A client cannot render a picture without receiving what the platform thinks of it at the same time. The URL is bounded surface area, never the master record.
The database lane carries job ownership and decisions. In `src/project/domain/states.ts`, every asset carries storage, quality, review, and publication as independent fields, plus `machineProduced`. `publicationBlockers` returns all blockers. `mayPublish()` is true only when that list is empty, so the answer and the explanation cannot drift apart.
The product lane has a different problem. Owners need to see work that is still blocked from publication. `src/quality/contracts/project-read-model.ts` calls this out directly: the newest produced asset can be visible to the owner while the hero claim remains reserved for cleared media. `publication_state: blocked` means unpublished. It has never meant hidden from the owner.
That distinction fixed a real product trap. If the dashboard hides every blocked output, the owner cannot review the work. If it treats every visible output as publishable, the public listing can outrun approval. The read model keeps those two surfaces separate.
## 4. Fanout is part of the product shape
The batch image processing API accepts one to twenty remote image URLs. It can request background removal, an enhancement level, responsive outputs, and watermarking. It can also associate the batch with a parent record identifier.
That upper bound controls more than cost. It keeps the work explainable. When a vehicle has a batch in flight, the status response can speak in terms of that vehicle, those image identifiers, and the job identifiers that matter. The video generation request has the same instinct in a stricter form: supplied image identifiers must be exactly three. Exact length makes downstream assembly simpler, and it rejects bad input before compute starts.
Priority needs restraint. The bulk endpoint accepts a priority hint. Elsewhere in the app, cache strategy uses levels where critical outranks high, then medium, then low. Priority helps when interactive work competes with background work. It should remain scheduling metadata. Once priority becomes hidden ownership, the product view and worker view start telling different stories.
The listing route avoids that split by keeping stage claims database-owned. Consumers claim through the remote procedure call or are invoked by cron. There is no pub/sub layer in that design. The capture flow polls.
Polling is a tradeoff, and I would not hide it. It burns time between readiness and the next drain. It also keeps claims, retries, and stale recovery in the same place as the media records. For this system, that was the cleaner failure mode.
## 5. Machines propose, humans accept
The listing generation stage has narrow authority. It takes the accepted hero set and writes proposed records for review. The design rule is blunt: machines propose, and only an actor shaped as `human:<uuid>` confirms. Condition findings follow the same line. Machines may propose findings. They do not confirm, reject, set severity, or drop them.
This matters because vision outputs look finished. A mask has pixels. A generated backdrop can look better than the source lot. A social frame can be well composed. The product still needs to separate produced from accepted.
The marketing generation stage starts from the accepted hero. It produces per-aspect re-renders for one-to-one, four-to-five, nine-to-sixteen, and sixteen-to-nine placements.
The output is letterboxed on dominant tone, so the model extends the scene while the car stays whole. It also stamps International Press Telecommunications Council (IPTC) DigitalSourceType and verifies the stamp on every produced frame. The implementation closed a measured gap where the core before-and-after publish path did not stamp.
That detail belongs inside generation. If a social frame is only an output URL, stamping becomes optional plumbing. Once publication depends on the asset carrying its receipts, verification has to happen where the frame is made.
The database gate is stricter. The project asset check makes published unrepresentable without digest verification, quality passed, and review approved or marked unnecessary. Takedown is never blocked.
I prefer a locked door to an apology in logs. A warning after an unreviewed image is public helps nobody.
## 6. Refusal has to be named
Vision workers fail in ways that ordinary endpoint errors flatten. They time out. They return partial output. A provider can accept a job and then vanish from the caller view. The operator still has to decide whether to retry, decline, or wait.
The framing drainer has one concrete answer. A slow matte becomes a per-item refusal with the note will retry next tick. It does not take the whole invocation down. That worker uses concurrency three and a per-item timeout of ninety thousand milliseconds. The comment explains the budget math: batches stay inside the route three hundred second ceiling at the maximum budget.
The same comment says the underlying fetch is not aborted. The worker may still finish. The next tick can ride a warm cache or record the result. The tradeoff is explicit: at most one orphan per slow item per tick, rather than eight.
That ugly detail belongs in the media custody model because timeout does not always mean failed image. Sometimes it means the drain cycle refused to spend all of its budget on one item. Sometimes the delayed result should be attached to the same attempt on the next pass.
The deletion worker follows the same rule from another direction. An API error or 404 alone does not prove a graphics processing unit (GPU) job stopped. Cleanup is allowed after a same-ID terminal response, a persisted terminal observation, or the provider maximum time-to-live after the fenced request. No endpoint-wide purge.
External systems can be vague. My database should name the uncertainty.
## 7. Derived media stays with its parent
The most dangerous derived asset is the one that looks better than its parent. A thumbnail can be crisp. A crop can be framed well. A studio render can look ready. None of that proves the source set passed review.
The listing work tightened this rule around parentage. Listing photos inherit their alpha at attempt one. Failed rerenders retry as their own family. A listing photo is never retried on its own. Orbit frames are reviewed as a set. A later change requires exact parent 360 approval before listing creation.
Those are product rules expressed as lineage. A child output does not declare independence because it has pixels. If the parent set lacks approval, children wait. If a rerender fails, it belongs to its attempt family. If a 360 has no ruling, the owner may ask for another render, while the system still refuses to treat absence as approval.
The 360 gate makes that concrete in `supabase/migrations/20261119000000_listing_360_approval_gate.sql`. The source-authority function returns a media delivery shape with schema version, delivery identifier, parent job, owner, actor, project, capture session, source set hash, generation, render attempt, source asset identifiers, listing batch, social selection, derived status, and failure code. The derived object carries enough parent identity to prevent a listing from being built from the wrong ring.
This is where the post differs from a pure provenance piece. Provenance tells you where an artifact came from. Custody tells the product what may happen next, who may decide, and which derived outputs must wait for their parent.
Any system that spends real money changing pixels needs ownership to move with the pixels, or the product will eventually believe the wrong thing.
---
๐ง **Listen to the audiobook** โ [Spotify](https://open.spotify.com/show/4ABVd5yDVfbX9HlV5JjT7D) ยท [Google Play](https://play.google.com/store/audiobooks/details/How_to_Architect_an_Enterprise_AI_System_And_Why_t?id=AQAAAECafz8_tM&hl=en) ยท [All platforms](https://www.craftedbydaniel.com/audiobook)
๐ฌ [Watch the visual overviews on YouTube](https://youtube.com/playlist?list=PLRteDbGJPYDb9XNjecvHplGlgW7tIv_q6)
๐ [Read the full 13-part series](https://www.craftedbydaniel.com/blog/series/how-to-architect-an-enterprise-ai-system-and-why-the-engineer-still-matters)
Top comments (0)