> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keeps.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Core concepts

> The data model behind keeps — immutable events, assets, properties, and how the backend folds them into one row per generation.

keeps has a small mental model. Internalize these five ideas — **assets & events**,
**asset\_id & correlation**, **properties**, **context**, and the **status
lifecycle** — and everything else in the SDK follows.

## Assets and events

Every call to `keeps.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.

```
capture(submitted)  ─┐
capture(completed)  ─┼──►  backend fold  ──►  one asset row
capture(completed)  ─┘                         (inputs, outputs, status, tags)
```

The fold rules are deliberate and order-independent:

* **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`, or `cancelled`), no non-terminal event can flip it
  back to in-flight — a late-arriving `submitted` can't reopen a completed
  asset. Among terminal events, the latest wins.

The SDK supports this fold by **omitting any field you don't set** on a given
`capture()` (it never sends empty strings), so a later event can add a field
without clobbering one an earlier event already set.

<Accordion title="How the fold resolves conflicts">
  * Every field folds **latest-non-empty per field**, ordered by
    `(ts, event_id)` — the `event_id` tie-break makes equal-`ts` events
    resolve in emit order (the SDK's ids are monotonic uuid7s).
  * **Terminal is sticky**: a late `submitted` or `processing` can't reopen a
    `completed`/`failed`/`cancelled` asset. One attempt = one `asset_id` — a
    retry is a **new** asset, linked to its predecessor with the reserved
    `reroll_of` property (and numbered with `attempt`).
  * A `completed` event **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 — only
    `completed`, `failed`, and `cancelled` set the completion time.
  * Every asset gets a `first_seen_at` anchor from its earliest event — even a
    one-shot `capture(status="completed", ...)` that never sent `submitted`
    sorts and windows correctly.
</Accordion>

<Accordion title="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 `group:` property
  key whose value is that asset's verdict in that contest:

  ```python theme={null}
  # each candidate is its own asset, tagged into the contest
  for i in range(4):
      ids.append(keeps.capture(
          model=model, provider="fal", status="submitted",
          inputs={"prompt": prompt},
          properties={f"group:{shot_id}": "candidate"},
      ))

  # later — whenever the choice happens — annotate the winner(s)
  keeps.capture(ids[winner], properties={f"group:{shot_id}": "picked"})

  # a video made FROM the picked image carries the lineage edge
  keeps.capture(model=video_model, provider="fal", status="submitted",
               inputs={"image_url": picked_url},
               properties={"source_asset_id": ids[winner]})
  ```

  Properties fold **per key**, so memberships are independent facts: the same
  asset can be `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.
</Accordion>

Because the fold is keyed on `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.

<Note>
  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.
</Note>

## asset\_id and correlation

The `asset_id` is the thread that ties an asset's events together. The pattern
is **mint once, reuse to append**:

```python theme={null}
import keeps

# First capture mints a uuid7 and returns it — hold on to this id.
asset_id = keeps.capture(
    model="flux-pro",
    status="submitted",
    inputs={"prompt": "a red bicycle in the rain"},
)

# ... your provider call runs ...

# Reuse the same asset_id to append the completion to the SAME asset.
keeps.capture(
    asset_id,
    status="completed",
    outputs=[{"url": "https://cdn.example.com/out.png"}],
)

# Later — a different request, maybe a different process — the user keeps it.
# Properties-only: annotations don't need to (and shouldn't) resend a status.
keeps.capture(asset_id, properties={"kept": "1"})
```

Passing the same `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.

<Warning>
  `capture()` does take a `provider=` argument, but it is a **dimension**, not a
  correlation key — it powers pricing and per-vendor reliability breakdowns and
  never joins events together. The provider's request id doesn't correlate
  either: tag it as the reserved `generation_id` property, where it becomes the
  join key for reconciling against the provider's invoice. Correlation is only
  the `asset_id` — keep it around (in your job record, your queue payload, your
  DB row) and pass it back.
</Warning>

`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:

```python theme={null}
asset_id = keeps.capture(asset_id=job.id, status="submitted", inputs={...})
```

## 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`.

```python theme={null}
keeps.capture(
    model="flux-pro",
    status="completed",
    outputs=[{"url": url}],
    properties={"film_id": "f_88", "shot_id": "s_12", "step": "keyframe"},
)
```

The fold for properties is **per key**, exactly like the scalar fields above:
for each property key, the value from the latest event (by `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:

```python theme={null}
# Submission — set two tags.
asset_id = keeps.capture(
    model="flux-pro",
    status="submitted",
    inputs={"prompt": p},
    properties={"film_id": "f_88", "shot_id": "s_12"},
)

# The completion, when it happens, carries its own fields.
keeps.capture(asset_id, status="completed", outputs=[{"url": url}])

# A partial bag is fine. This merges in `kept` and leaves film_id and shot_id
# intact — the asset's properties become {film_id, shot_id, kept}. No status:
# an annotation is a properties-only event.
keeps.capture(asset_id, properties={"kept": "1"})
```

You don't have to resend the full property bag on every `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.

<Note>
  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.
</Note>

A practical habit: keep the asset's constant tags in one dict and let
`keeps.context()` apply them automatically, then add per-event keys (`kept`,
`approved`, …) on the calls that need them. The next section shows how.

<Note>
  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.
</Note>

## 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.

```python theme={null}
with keeps.context(user_id="u_42", film_id="f_88", shot_id="s_12"):
    # Both captures inherit user_id + film_id + shot_id automatically.
    a1 = keeps.capture(model="flux-pro", status="submitted", inputs={"prompt": p1})
    a2 = keeps.capture(model="flux-pro", status="submitted", inputs={"prompt": p2})
```

The `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:

```python theme={null}
with keeps.context(user_id="u_42", film_id="f_88"):
    keeps.capture(model="flux-pro", status="submitted", inputs={...})
    # tags: user_id=u_42, film_id=f_88

    with keeps.context(shot_id="s_12"):
        keeps.capture(model="flux-pro", status="submitted", inputs={...})
        # tags: user_id=u_42, film_id=f_88, shot_id=s_12  (merged, inner adds)

    with keeps.context(film_id="f_99"):
        keeps.capture(model="flux-pro", status="submitted", inputs={...})
        # tags: user_id=u_42, film_id=f_99  (inner overrides film_id)
```

Because context tags are re-applied to *every* `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.

<Note>
  `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](/reliability).
</Note>

## Status lifecycle

`status` describes where a generation is. The lifecycle is simple:

```
submitted  ──►  completed
           ├─►  failed
           └─►  cancelled
```

| 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.

```python theme={null}
asset_id = keeps.capture(model="flux-pro", status="submitted", inputs={"prompt": p})

try:
    out = run_provider(p)
    keeps.capture(asset_id, status="completed", outputs=[{"url": out.url}])
except ProviderError as e:
    keeps.capture(asset_id, status="failed", error={"message": str(e)})
```

Two things the SDK deliberately does **not** do, because they're the backend's
job:

* **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.

This keeps the SDK thin and your data correct even as pricing and error
taxonomies evolve: re-classification happens on already-recorded events, with no
SDK change or redeploy.

<Note>
  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.
</Note>

## Where to go next

<CardGroup cols={2}>
  <Card title="Recording provider calls" href="/recording-provider-calls">
    The submit → complete → feedback pattern around fal, Replicate, and OpenAI.
  </Card>

  <Card title="Reliability" href="/reliability">
    Buffering, flushing, and why short-lived workers must call `flush()`.
  </Card>

  <Card title="API reference" href="/api-reference">
    Every parameter on `init`, `capture`, `context`, `flush`, and `stats`.
  </Card>

  <Card title="Privacy & safety" href="/privacy-and-safety">
    What leaves your servers, verbatim capture, and the fail-open guarantee.
  </Card>
</CardGroup>
