Skip to content

Character assets use scope plus role, with lineage as the generation guard

Context

A character binding today is one free-form role_key string (apps/sirloin/internal/app/migrate/schema/133_character_assets_identity.sql:69-74). That string is carrying two questions at once: what kind of use is this? and which slot inside that use? The answers are reconstructed from four independent signals that do not know each other — a generation_ prefix parse in characterruntimecontrol.go, policy.generation.references in the recipe, whether a role appears in bindings.assets versus assetOutputs, and provenance.provider on the asset row. None of those signals is authoritative.

The generation guard makes the same mistake. Eligibility is a string equality:

var provenance struct {
Provider string `json:"provider"`
}
if err := json.Unmarshal(matched[0].Provenance, &provenance); err != nil ||
provenance.Provider != expectedProvider {
return CharacterProjectionAsset{}, ErrCharacterGenerationProjectionInvalid
}

expectedProvider is hardcoded to "source-asset-copy", and recipe visibility repeats the same check (apps/sirloin/internal/pkg/storage/characterrecipe.go:97-101). Any asset that was cropped, remixed, or saved through a different node is therefore ineligible by construction. That is why dataset_save cannot fill a generation role today, and why promote_assets exists as a second node that only copies verified uploads while stamping the one accepted provider string.

Two further defects follow from the single axis. An unbound rejected image is invisible only because every customer read joins through character_asset_bindings — there is no policy that says evidence must stay hidden. And kitsuneTypeToGenerationRole invents a role by concatenating generation_ onto a Kitsune type (apps/sirloin/internal/app/services/characterruntimecontrol.go:33, 237-243); an unknown type silently produces a shorter dataset list instead of a hard error.

The product need is the opposite of what this model allows: one upload may fan out into a crop, a model rewrite, and several previews, and any of those results may become the generation reference — provided every generation asset still traces back to a verified upload.

Decision

Character asset bindings are a pair scope + role, not a single role_key. Scope is a closed enum that carries policy. Role is an open slot name inside that scope, declared by the recipe.

Scope is policy

The enum is upload, generation, and preview. Each value answers five axes:

ScopeDurability momentRetentionIdentity datasetLineage requiredCustomer-visible
uploadupload finalizedurableyesrootyes
generationexecution commitdurableyesyes, from verified uploadyes
previewexecution commitdeletable (deferred)nonoyes

Scope lives on the binding, not on the asset. The same bytes may already be bound to more than one role; a no-transform promotion may therefore become a second binding rather than a second object, subject to storage-prefix policy.

preview stays out of the identity snapshot and out of requireCharacterStateAssets. Deletion of previews is declared by the enum but not executed in this round — they remain for debug and BI.

Rejection evidence has no scope

A rejected image is stored as an asset in state=deleted, with its error codes and analysis metadata in provenance, and is never bound. It therefore carries no scope and no role.

This is deliberate, and it is the one place where the “every asset has a scope” symmetry is refused. Giving evidence a scope would pull it into the same join every customer read already performs, and its invisibility would then rest on every read path remembering a filter. Unbound, it is invisible by construction — a stronger guarantee than the one a fourth enum value would have bought.

The admin need behind evidence is failure analysis, and the material for that is failure_reason_code on the execution, not the bytes. The bytes are retained so a future admin view can show a character’s rejected images; nothing reads them today.

One filter is still required and is unrelated to evidence: customer projection queries must filter assets.state in SQL. A rejected upload after rejectFailedCharacterUpload sits in scope upload and must still not be shown — only the state filter catches it. Today the projection paths (roster, management) do not filter state at all.

Role is a slot

Role is ^[a-z][a-z0-9_-]{0,63}$, unique per (character_id, scope, role, ordinal). The Kitsune trio (face_frontal, full_body, full_body_any) is a declared mapping for scope generation only; adapters that need a closed set pin it there. Upload roles stay recipe-declared. Concatenating a prefix is forbidden — unknown Kitsune types hard-error.

Avatar selection stays on policy.generation.displayRole pointing at a generation role (today face_frontal after the scope split).

Lineage is an edge

characters.asset_derivations stores (asset_id, source_asset_id) with composite foreign keys on assets(id, account_id) on both sides and a cycle guard. Fan-out and fan-in both fit; a single parent column does not.

Nodes declare sources by binding reference (derived_from: [{scope, role, ordinal}]). Sirloin resolves those to asset IDs at commit, inserts the edges, and rejects the commit if any declared source is missing or unreachable. An append-only character_asset_binding_history records every bind and rebind.

Generation guard is reachability

exactCharacterGenerationAsset stops comparing provenance.provider. For scope generation only, it requires that every lineage root of the bound asset is a verified source_kind='upload' asset, found by a depth-limited recursive CTE over asset_derivations. Fan-in requires all sources to reach a verified upload. Recipe customer-availability stops requiring referenceProvider == "source-asset-copy".

One materializing node

character:materialize_asset replaces character_promote_assets and character:dataset_save. It accepts either an existing binding or an object key, sniffs content type from bytes, writes a content-addressed key, and emits a terminal asset with scope, role, optional errors/metadata provenance, and declared lineage. Intermediate bytes land under characters/scratch/; only materialization enters the digest-bound namespace.

dataset_save is removed outright. Rejection evidence is written by the materializing node as an unbound asset; “training material” is scope generation. The dual-read ADR’s allowlist fork for DATASET_MANIPULATION is satisfied by dropping the node — character compute fixtures already seed purpose as CHARACTER_COMPUTE only. The purpose enum itself may remain for image-only call_model graphs.

A declining run still emits its rejected bytes as an unbound asset, which is not a binding and so does not reach the commit’s asset contract. Bound assets on a failure output continue to fail the commit unconditionally.

Consequences

  • Bindings, recipe contracts, execution assets, and projection reads all grow explicit scope and role fields; role_key and the generation_ prefix go away after a Sirloin migration that truncates local bindings — the branch is not on production, so no backfill is written.
  • Generation can consume cropped or model-derived references, provided lineage reaches a verified upload. The promote_assets / dataset_save split disappears.
  • Evidence is durable and queryable for support, and invisible to Brisket because it is unbound. Session and projection paths stop disagreeing about assets.state, which is what hides a rejected upload — that one does carry a scope.
  • A reaper can delete unbound non-ancestors and scratch objects only after lineage exists; before that, “unbound” is ambiguous. Unbound no longer implies disposable either: rejection evidence is deliberately unbound, so the reaper must exclude assets whose provenance carries errors, not merely those that are unbound and not an ancestor. Two further references outlive a binding and protect a row just as hard: character_states is append-only and a restore refuses a look whose assets are no longer available, so a snapshot naming an asset keeps it; and a preview that a re-run replaced is unbound but retained, because this decision keeps previews rather than collecting them. See Character Recipe Engine for how the reaper reads those four references.
  • Character compute gains a real age/celebrity moderation checkpoint (MATERIALIZE_ASSET) on customer-visible scopes, driven by workflow.explicitnessLevel. That closes a hole where age checks existed only on ONBOARDING and POST_GENERATION, and media_output is banned from CHARACTER_COMPUTE.
  • A slot may be declared clean: character:materialize_asset takes require_clean, which judges nudity as an SFW run would even on an NSFW workflow (covers displayRole abuse). Default off. It sits on the node and not in the recipe because the recipe never reaches Brain at execution time — Sirloin sends only the bindings its inputBindings resolve, and the pin projection Brain returns carries no graph, so a recipe-level flag would be a claim neither service could enforce. The recipe still governs it: the flag is part of the graph, and the recipe pins the graph digest.
  • Out of scope this round: real generation for VI placeholder routes (character_reference_assets), preview deletion, and Brain’s legacy onboarding tables / dual-read facade (only the CHARACTER_COMPUTE write into onboarding_images is cut).

Rollback

Leave the ADR Proposed until the scope migration and the reachability guard land together. Rolling back after backfill means restoring role_key from scope + role and reintroducing the provider string check — doable while no customer-facing recipe depends on derived generation assets. Once a published recipe materializes a crop into generation, rollback requires republishing that recipe onto promote-only graphs first.

Alternatives Considered

  • Keep role_key and teach the guard more provider strings. Rejected. Every new production path would need another allowlisted stamp, and scope policy (visibility, retention, lineage) would still be inferred from prefixes.
  • A fourth evidence scope. Rejected. It would give rejected images a binding, pulling them into the same join every customer read performs, so their invisibility would depend on every read path remembering a scope filter. Unbound evidence is invisible by construction. The scope also bought nothing the admin need requires: failure analysis reads failure_reason_code on the execution.
  • Single source_asset_id column instead of an edge table. Rejected. Fan-in (merge two uploads) and fan-out (one upload → face + body) both need a DAG; a column encodes only one parent.
  • Keep dataset_save for evidence / training. Rejected. Evidence is an unbound asset the materializing node can write; training material is generation. The node would survive only as the Brain-leg write for DATASET_MANIPULATION, which character fixtures never use.
  • Read nudity policy from the role (isNsfwImageType) instead of workflow explicitness. Rejected. call_model moderates inputs on PRE_GENERATION before any role exists; splitting input and output policy across the same graph is worse than staying consistent with other purposes. Role-level “must be clean” is an opt-in overlay on the materializing node instead.
  • Reuse the ONBOARDING moderation checkpoint for materialize. Rejected. It carries nsfwMissingNudity (fail when NSFW content lacks nudity) and resolveVersionedPath, both wrong for content-addressed generation assets produced by an NSFW workflow that intentionally emits a clean reference.