Skip to content

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 undefined on 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_save is deleted, so its allowlist is gone with it and no purpose can reach a half-path. Rejection evidence moved to character:materialize_asset (unbound, state=deleted) and “training material” is scope generation, committed through terminalAssets (ADR 2026-08-11). The section below is kept as the reasoning that fixed the condition; the DATASET_MANIPULATION purpose itself survives for image-only call_model graphs.

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:

  1. drop DATASET_MANIPULATION from the node’s allowedPurposes, or
  2. 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_save becomes 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 undefined for 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_save was blocked for DATASET_MANIPULATION until that purpose left the allowlist or gained a character-scoped Sirloin write without character_execution_id. CHARACTER_COMPUTE was 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_character when a recipe omits a legacy named field. Rejected. A recipe is the current declared field set; absence means undefined, 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_MANIPULATION a 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.