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:
| Scope | Durability moment | Retention | Identity dataset | Lineage required | Customer-visible |
|---|---|---|---|---|---|
upload | upload finalize | durable | yes | root | yes |
generation | execution commit | durable | yes | yes, from verified upload | yes |
preview | execution commit | deletable (deferred) | no | no | yes |
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
scopeandrolefields;role_keyand thegeneration_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_savesplit 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_statesis 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 byworkflow.explicitnessLevel. That closes a hole where age checks existed only onONBOARDINGandPOST_GENERATION, andmedia_outputis banned fromCHARACTER_COMPUTE. - A slot may be declared clean:
character:materialize_assettakesrequire_clean, which judges nudity as an SFW run would even on an NSFW workflow (coversdisplayRoleabuse). 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 itsinputBindingsresolve, 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 intoonboarding_imagesis 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_keyand 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
evidencescope. 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 readsfailure_reason_codeon the execution. - Single
source_asset_idcolumn 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_savefor evidence / training. Rejected. Evidence is an unbound asset the materializing node can write; training material isgeneration. The node would survive only as the Brain-leg write forDATASET_MANIPULATION, which character fixtures never use. - Read nudity policy from the role (
isNsfwImageType) instead of workflow explicitness. Rejected.call_modelmoderates inputs onPRE_GENERATIONbefore 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
ONBOARDINGmoderation checkpoint for materialize. Rejected. It carriesnsfwMissingNudity(fail when NSFW content lacks nudity) andresolveVersionedPath, both wrong for content-addressed generation assets produced by an NSFW workflow that intentionally emits a clean reference.