Legacy character data migrates to Sirloin by dual-read, not dual-write
Context
On main, Brain owns characters and their onboarding images. The recipe engine
moves that ownership to Sirloin, which becomes the single store for characters,
assets, and the role bindings between them. Both stores will therefore hold real
data at the same time, for as long as legacy characters exist.
The obvious way to bridge that is dual-write: every save lands in both stores
until Brain can be dropped. One node already works this way — dataset_save
writes an onboarding_images row in Brain and then posts the same item to
Sirloin’s ingest webhook. Its own port documentation calls the Brain leg
transitional and droppable.
Dual-write does not pay for itself here. It doubles the write path for records that are not the problem, and it does nothing about the records that are: the characters that already exist only in Brain. Those still need an atomic migration of characters and assets, which is hard enough that it is not planned. So dual-write buys ongoing complexity without removing the reason it was introduced.
The contact surface with legacy data turns out to be narrow. Only two nodes read
character data out of Brain, both live in data-access/, and both are already
banned from CHARACTER_COMPUTE because creation-side inputs come from Sirloin
(apps/brain/src/modules/domain/workflow/nodes/data-access/load-character-dataset.node.ts:19-27).
The exposure is therefore confined to the media-generation pipeline; the
creation path is already Sirloin-native.
Both nodes also already delegate through a facade rather than touching storage, and the dataset node’s output contract is a single array of strings:
readonly outputSchema: NodeSchema = { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' }, description: 'Deliverable paths of the used onboarding images', }, }, };A seam that thin, behind an interface that already exists, is a better place to put the migration than the write path.
Annex: FOXY-608 locked the opposite call direction for recipes
FOXY-608 closed with an explicit ruling that brain does not call sirloin for recipe ownership. The store ADR and the workflow-pin decision (2026-07-21) put recipe contracts in Sirloin and made Brain the pinned compute plane Sirloin dials — Sirloin → Brain over HTTP execute-by-id, not Brain → Sirloin over a recipe client.
That ruling still holds for recipes. This ADR does not reopen a Brain client that reads or manages recipes in Sirloin. It opens a different, narrower channel: Brain reading character dataset and attribute data that Sirloin now owns, so media-generation workflows can resolve a character regardless of which store holds it.
The seam already exists. CharacterRuntimeControlService is defined in
proto/brain/v1/character_runtime.proto, implemented by Sirloin, and already
on Brain’s stub generation list. Today it exposes only Acquire/Commit for
mid-flight sync use; dual-read adds narrow read RPCs on that same service. The
service token that already guards Strip’s gRPC surface is a prerequisite before
any character-data read lands there — account_id is a request field today, not
an authenticated claim.
Decision
Character data migrates by dual-read. New characters are written to Sirloin only; nothing is written twice. The read nodes in Brain resolve from Sirloin first and fall back to Brain, so a workflow sees one uniform answer regardless of which store holds the character.
The adapter goes in CharacterFacade
(apps/brain/src/modules/domain/character/services/character.facade.ts:99-113),
not in the nodes. Node types, graphs, and graph digests are untouched, so no
recipe is repinned to land this.
Brain reaches Sirloin through new read RPCs on CharacterRuntimeControlService,
behind a service-token interceptor. This deliberately annexes FOXY-608’s
brain-does-not-call-sirloin rule for character data reads during migration,
while leaving recipe ownership and pin consistency on the Sirloin → Brain path
that FOXY-608 delivered.
Precedence is per character, and never a union
A character resolves entirely from one store. If it carries a recipe attribution, Sirloin is the sole source of truth for it and Brain is not consulted; otherwise it is a legacy character and Brain answers.
Merging role by role is forbidden. A dataset assembled from both stores would mix photographs from two eras of the same person, which is identity drift in everything generated downstream.
Fallback triggers on absence, never on failure
Brain is consulted only when Sirloin answers that it has nothing for this character. A timeout, a connection error, or any other failure fails the workflow. Falling back on failure would silently serve a migrated character its stale legacy dataset, which is worse than not answering.
The adapter preserves the existing contract exactly
The types input is both a filter and an ordering instruction. The current
implementation groups results in the order the caller requested and preserves
the order within each group, and resolves each image to its deliverable path.
The Sirloin-backed path reproduces that: role order follows requested type
order, ordinal orders within a role, and the asset’s object key is the path.
Omitting types returns the whole used dataset, as today.
Role keys and Kitsune types map by prefix
Sirloin dataset roles are the lowercased Kitsune type with a generation_
prefix, so the translation is mechanical in both directions. The current
fixtures declare generation_face_frontal, generation_full_body, and
generation_full_body_any, matching three of the nine
KitsuneOnboardingImageType values
(apps/brain/src/modules/domain/api/types/common.ts:1-11).
The prefix does real work: it distinguishes dataset roles from other durable
roles such as preview, and from upload source roles such as face_chest,
which must not appear in a dataset read. Roles without the prefix are not
dataset members and the adapter ignores them.
load_character projects what the recipe declares
The settings node exposes eleven named attributes plus a recipe-declared
settings object
(apps/brain/src/modules/domain/workflow/nodes/data-access/load-character.node.ts:43-63).
A recipe is the current declared field set, not a permanent schema. When a
Sirloin-native character is projected onto that node:
- Fields the recipe declares map into the matching named slot or into
settings. - Fields the recipe does not declare return
undefinedon the named slot. - The node does not fail, and it does not invent defaults for missing fields.
A later recipe that adds something like is_tattoos starts filling that slot
without a Brain change. Media-generation graphs that still bind a legacy field
the recipe no longer carries see undefined and must tolerate it — that is the
accepted degradation, not a silent invention of values.
The dataset node remains the lossless half of this pair: its contract is one ordered array of object keys, and the role-prefix mapping covers it.
Progress is measured by query, not by counter
No fallback metric is instrumented. When the question “is anything still landing without a recipe” needs an answer, a single database query answers it, and that is also the signal that the Brain leg can be deleted.
DATASET_MANIPULATION stays Brain-only until a Sirloin write exists
Settled 2026-08-12 by option 1.
character:dataset_saveis deleted, so its allowlist is gone with it and no purpose can reach a half-path. Rejection evidence moved tocharacter:materialize_asset(unbound,state=deleted) and “training material” is scopegeneration, committed throughterminalAssets(ADR 2026-08-11). The section below is kept as the reasoning that fixed the condition; theDATASET_MANIPULATIONpurpose itself survives for image-onlycall_modelgraphs.
character:dataset_save allows both CHARACTER_COMPUTE and
DATASET_MANIPULATION. Only CHARACTER_COMPUTE injects
character_execution_id, and every Sirloin write path for this node requires
it: accepted images land through terminalAssets at character-execution
commit, and rejected images use mid-flight ingest keyed on that same id.
DATASET_MANIPULATION therefore cannot reach Sirloin today. It keeps writing
Brain onboarding_images only. That is acceptable while the Brain leg still
exists, and no POC fixture or recipe depends on this purpose for
dataset_save.
When the Brain leg is removed, do not invent a silent half-path. Either:
- drop
DATASET_MANIPULATIONfrom the node’sallowedPurposes, or - add an explicit character-scoped Sirloin write that does not require a character execution session.
Until one of those ships, removing the Brain leg must fail closed for
DATASET_MANIPULATION runs of dataset_save rather than pretending the item
was stored.
Consequences
Benefits
- No atomic migration is required, and no cutover window exists. A legacy character keeps working untouched for as long as it is never migrated.
- Writes stay single. The transitional Brain leg in
dataset_savebecomes removable rather than load-bearing, and no new double-write is introduced. - Nothing repins. The change lands entirely behind a facade, below the node contract that published graphs bind.
- The blast radius is the media pipeline only, because both read nodes are
already excluded from
CHARACTER_COMPUTE. - Reads are safe to get wrong in a way writes are not: a bad read returns nothing, while a bad dual-write leaves two stores disagreeing permanently.
- Both read nodes can ship together: missing recipe fields no longer block the settings projection.
Costs and risks
- Brain keeps a live dependency on Sirloin on the read path, so Sirloin availability becomes a media-generation dependency. This is deliberate, given the fail-closed rule above.
- This annexes FOXY-608 for character-data reads. The recipe plane stays Sirloin → Brain; the character-data plane becomes Brain → Sirloin on a service-authenticated control channel. Mixing those concerns would undo the pin model.
- Two stores stay live indefinitely. Without a migration, the Brain leg is deleted only when no legacy character is still generating, which may be never for abandoned characters.
- The mapping rule is convention, not a declared contract. A recipe that
declares a dataset role without the
generation_prefix silently drops out of legacy reads. Worth a validation rule when the mapping stops being a three-entry table. - No current fixture exercises the six NSFW Kitsune types. The prefix rule extends to them naturally, but nothing proves it yet.
- Media-generation graphs that still expect legacy named attributes may see
undefinedfor recipe-driven characters. That is preferred to inventing values or failing the node; graphs that cannot tolerate absence need their own update. - Dropping the Brain write in
dataset_savewas blocked forDATASET_MANIPULATIONuntil that purpose left the allowlist or gained a character-scoped Sirloin write withoutcharacter_execution_id.CHARACTER_COMPUTEwas already Sirloin-native via commit / rejected ingest. Resolved 2026-08-12: the node and its Brain leg are deleted outright, so the allowlist no longer exists.
Rollback
Remove the Sirloin branch from the facade and the read falls back to Brain behaviour, because no data was moved and no contract changed. Characters written to Sirloin in the meantime become unreadable to the media pipeline until the branch is restored, so rollback is safe only while no recipe-driven character is generating.
Alternatives Considered
- Dual-write to both stores until Brain is dropped. Rejected. It doubles the write path for new records while leaving the actual problem — records that exist only in Brain — untouched, so it adds permanent complexity without removing the need for a migration.
- Atomic migration of characters and assets into Sirloin. Rejected as impractical. Beyond moving rows and objects, a legacy character has no recipe, no pinned workflow, and no execution history, so it cannot be made Sirloin-native without fabricating a run that never happened.
- Backfill on read: write into Sirloin whenever the Brain fallback is hit. Deferred rather than rejected. It turns the impossible atomic migration into an incremental one driven by traffic, but it inherits the recipe-attribution problem above and would have to copy every asset to a content-addressed key to avoid corrupting state restore. Captured as FOXY-915. The current plan is to encourage re-onboarding instead of migrating.
- Give recipe-driven characters new read nodes and leave the old ones alone. Rejected. It doubles the node vocabulary for authors and pushes the store choice into every graph, which is exactly the decision a facade should be making once.
- Fail
load_characterwhen a recipe omits a legacy named field. Rejected. A recipe is the current declared field set; absence meansundefined, not a hard stop. Failing would block dual-read until every recipe restated the legacy attribute list. - Invent defaults for undeclared legacy fields. Rejected. Silent invention would lie to media-generation about identity attributes the recipe never claimed.
- Build a new Brain → Sirloin recipe client despite FOXY-608. Rejected. That would reopen the store/pin decision. Dual-read uses the existing Sirloin-implemented control service for character data only.
- Give
DATASET_MANIPULATIONa silent Sirloin write without an execution session when the Brain leg drops. Rejected. Sirloin’s ingest and commit paths are execution-scoped by design; inventing an unbound write would leave assets without the account/character lock and provenance model those paths provide. Choose an explicit allowlist change or an explicit new write RPC.