Assets and events
Every call tokeeps.capture() emits one immutable event. Events are never
edited or deleted; you only ever append new ones. An event is a fact about a
generation at a moment in time: “this was submitted with these inputs,” “this
completed with these outputs,” “this finished and the user kept it.”
An asset is one generation — a single image, video, or audio clip your app
produces. It is the unit you analyze: one row in the dashboard, one entry in the
requests feed. You never create an asset directly. The backend folds all the
events that share an asset_id into a single materialized row.
- Latest non-empty wins, per field. Every field folds to the value from
the latest event that actually set it, ordered by
(ts, event_id)— so equal-timestamp events resolve deterministically, and an event that doesn’t carry a field can never blank out a value an earlier event set. - Status is terminal-sticky. Once an asset reaches a terminal status
(
completed,failed, orcancelled), no non-terminal event can flip it back to in-flight — a late-arrivingsubmittedcan’t reopen a completed asset. Among terminal events, the latest wins.
capture() (it never sends empty strings), so a later event can add a field
without clobbering one an earlier event already set.
How the fold resolves conflicts
How the fold resolves conflicts
- Every field folds latest-non-empty per field, ordered by
(ts, event_id)— theevent_idtie-break makes equal-tsevents resolve in emit order (the SDK’s ids are monotonic uuid7s). - Terminal is sticky: a late
submittedorprocessingcan’t reopen acompleted/failed/cancelledasset. One attempt = oneasset_id— a retry is a new asset, linked to its predecessor with the reservedreroll_ofproperty (and numbered withattempt). - A
completedevent clears any earlier error, so a fail-then-succeed asset never shows a stale error next to a completed status. (A properties-only annotation on a failed asset leaves its error alone.) - Unknown statuses are non-terminal:
status="succeeded"(or a typo) folds as the asset’s current status but never marks it finished — onlycompleted,failed, andcancelledset the completion time. - Every asset gets a
first_seen_atanchor from its earliest event — even a one-shotcapture(status="completed", ...)that never sentsubmittedsorts and windows correctly.
Contests: N candidates, someone picks — the group: pattern
Contests: N candidates, someone picks — the group: pattern
When generations compete (generate four shots, keep one), record each
candidate as its own asset and mark membership with a Properties fold per key, so memberships are independent facts: the same
asset can be
group: property
key whose value is that asset’s verdict in that contest:picked in one contest and candidate in another, contests can
have several winners (annotate several) or none (nobody picked), a
head-to-head is just a 2-member group, and you can add an asset to a contest
days after it was generated. This is what powers pick-rate and
“why does a generation win” (trait lift among winners vs candidates) on the
dashboard — and source_asset_id is what makes cross-modality funnels
(“how many images before one becomes a video?”) answerable.asset_id and not on arrival time, ingestion is
out-of-order tolerant: a completion event can arrive before its submission
(common with webhooks and forked workers) and the asset still materializes
correctly. It’s also idempotent — every event carries a unique event_id,
so a retried or duplicated send is deduped, never double-counted.
You don’t have to send a “submitted” then a “completed” as two separate events.
A single
capture(status="completed", inputs=..., outputs=...) is a complete,
valid asset on its own. Split into two events only when submission and
completion happen at different times or in different processes.asset_id and correlation
Theasset_id is the thread that ties an asset’s events together. The pattern
is mint once, reuse to append:
asset_id back is the correlation mechanism in the SDK.
Submission, completion, and any later annotation all land on one asset purely
because they share that id. The asset_id is the only thing you need to carry
across function boundaries, async tasks, or process boundaries.
capture() always returns the asset_id, even when the SDK is disabled or
uninitialized — so your correlation logic keeps working in every environment. If
you want to control the id yourself (for example, to match your own job id),
pass it on the first call instead of letting keeps mint one:
Properties
properties are your dimensions — the tags you group and break down by.
They’re stored server-side as a Map(String, String), never as schema columns.
This is how you slice the data along axes keeps knows nothing about: film_id,
shot_id, step, prompt_version, surface, tier.
ts) that set that
key wins. Keys you don’t set on an event are left untouched — so appending a
partial bag adds to the asset’s tags rather than replacing them.
Here’s the pattern made concrete. You submit with two tags, then later — once
the user keeps the result — append just the new tag:
capture() — append
only the keys an event actually introduces. (Resending the whole bag is still
harmless, just no longer required.) Omitting properties entirely (or passing
an empty map) never overwrites anything.
To change a tag’s value, send that key again with the new value on a later
event — latest
ts wins. To clear a tag, send the key with a None
value: it folds like any value and reads back empty. Values get one canonical
string encoding server-side — send True/False and they’re stored as
'true'/'false', numbers and dicts as compact JSON.keeps.context() apply them automatically, then add per-event keys (kept,
approved, …) on the calls that need them. The next section shows how.
The total property bag has a budget of 8192 bytes. An over-budget bag has its
values trimmed to fit — keys are never replaced with a stub, so your
group-by dimensions stay clean — and the cut is flagged in the event’s
truncated field. Still: keep tags low-cardinality and small — they’re
groupby keys, not a place to log payloads.Context
keeps.context() applies ambient tags to every capture() inside a block, so
you don’t have to thread the full bag through each call by hand. It tags only —
it does not flush.
user_id keyword sets the event’s user; every other keyword becomes a
property tag. Resolution order for any single key is: an explicit argument on
capture() wins, then the innermost context(), then the init() default.
Blocks nest, and the inner block wins per key:
capture() in the block, they’re
the cleanest way to keep an asset’s constant tags (film_id, shot_id) on each
event without threading them by hand — provided each event for an asset runs
inside the same context. Per-event keys that aren’t constant (kept,
approved) can just be passed on the individual capture() that introduces
them; the per-key fold merges them in.
keeps.context() follows the current thread/task and is copied into asyncio
tasks created inside the block. It does not leak into threads you spawn yourself.
And to be explicit: leaving a context block does not deliver anything — call
keeps.flush() when you need to guarantee events are sent. See
Reliability.Status lifecycle
status describes where a generation is. The lifecycle is simple:
| Status | Meaning | Typical fields |
|---|---|---|
submitted | The generation was kicked off. | inputs, model |
completed | It finished successfully. | outputs (URLs/refs) |
failed | It errored. | error (verbatim from the provider) |
cancelled | It was called off. | — (the spelling canceled is normalized at ingest) |
status is an open set — any string is accepted, and the SDK doesn’t validate
it. But the terminal vocabulary is fixed: exactly completed, failed,
and cancelled finish an asset. Any other status (queued, processing,
or a typo like succeeded) folds as the asset’s current state but never marks
it finished — so stick to the reserved words for the moments that matter.
- Cost is computed server-side from pricing tables keyed on
model. You never send a price; you don’t have to keep pricing in your app at all. - Errors are normalized server-side into a taxonomy. The SDK ships the provider’s error verbatim — pass the raw provider message and let the backend classify it. Don’t pre-bucket errors yourself.
Put URLs, asset refs, and metadata in
outputs; media files stay wherever you
host them. If you hand keeps bytes, only their length is recorded.Where to go next
Recording provider calls
The submit → complete → feedback pattern around fal, Replicate, and OpenAI.
Reliability
Buffering, flushing, and why short-lived workers must call
flush().API reference
Every parameter on
init, capture, context, flush, and stats.Privacy & safety
What leaves your servers, verbatim capture, and the fail-open guarantee.