Character Node — Hybrid Role Dropdown + Identity-Lock (Design)

Date: 2026-07-01 Status: Approved (brainstorming) — revised after adversarial full-review; pending implementation plan Branch: feat/character-node-default-role

Problem

The character node’s title-row dropdown (frontend/src/components/nodes/character-node.tsx:345-368) still renders the legacy usage-mode vocabulary — “Identical”, “Face only”, “Face + Pose”, “Pose only”, “Emotion only”, “Style only”, “Name only”, “None” — via usageModeLabel(USAGE_MODES), with no hybrid gate. The Unified Reference Roles work (Phase D) migrated the mention pills and the @-autocomplete to the new role vocabulary, but the character node’s own dropdown was never in scope. IMAGE_REFERENCE_FORMAT now defaults to "hybrid" everywhere (frontend/src/lib/image-reference-format.ts:23), so the node is the last place speaking the old language.

It is not merely cosmetic. In hybrid, the node’s defaultUsageMode only reaches output through two of the three wired-character resolution paths, and even there it is lossy:

  1. Extras path (renderExtraRefsHybrid, prompt-builder.ts:1048) — role = firstSightExtraRole(defaultUsageMode, "wired-character"). This maps only face/pose/style to themselves; identical/face-pose/emotion/name/none all collapse to "person". So 5 of the 8 dropdown options are dead on this path.
  2. Canonical-fallback path (renderCanonicalFallbackHybrid, prompt-builder.ts:973; video video-reference-resolver.ts:515) — used when a character node is wired to a generator but not @-mentioned and not an extra. This is the most common wiring, and it hardcodes defaultRoleForSource("wired-character") = "person", ignoring defaultUsageMode entirely today.
  3. Mention path (resolveCharacterMentionsHybrid, prompt-builder.ts:318; video video-reference-resolver.ts:298) — an un-roled @-mention also falls straight to "person", ignoring the node default.

So the node dropdown is (a) mislabelled, (b) lossy where it works, and (c) ignored entirely for the most common wiring. The fix must make the node’s default role reach all paths on both image and video.

Scope note: only the character node has this dropdown; location-node.tsx / object-node.tsx / creature-node.tsx have no equivalent control (verified).

Decisions (from brainstorming)

  1. Vocabulary — full pill parity. The node’s hybrid dropdown offers the complete character role vocabulary (person / face / clothes / hair / pose / expression / style) + Custom…, matching the mention pill.
  2. Also expose identity-lock on the node (the existing off/soft/strict field), not just the role dropdown.
  3. Identity-lock — 3 real levels, reuse the existing field. off → no lock line, soft → mild “preserve likeness” line, strict → strong “match exactly” clamp. Reuse CharacterNodeData.identityLock; accept that existing nodes (default "soft") will emit a mild identity line in hybrid.

Design

Data model

Field Location Notes
new defaultRole?: string CharacterNodeData (frontend/src/types/nodes.ts) Hybrid default role slug (pre-sanitized). undefined ⇒ source default "person". Written only by the hybrid dropdown. Workflow save is passthrough (z.record(z.unknown())), so no workflow-route Zod change.
defaultUsageMode CharacterNodeData Unchanged — remains the legacy-mode default; keeps usageModeDirective/effectiveMode correctly typed. A separate field is required because role slugs person/clothes/hair/expression are not UsageMode members (compile error) and usageModeDirective has no case for them (returns undefined — poisons the legacy + video-legacy path, character-usage-mode.ts:63-81, video-reference-resolver.ts:534).
new defaultRole?: string ConnectedReference (packages/shared/src/types.ts) Carries the node default into the shared resolvers. Forces a matching field in backend/src/lib/connected-reference-schema.ts (route schema for structured refs) — pinned by the drift-guard test backend/src/routes/__tests__/generate-image.test.ts:134-188 (compile-time Exclude<TypeKeys,SchemaKeys> + Record<keyof ConnectedReference, true> sample + runtime key-count). Omitting it hard-fails CI.
new defaultRole?: string CharacterMeta (video-reference-resolver.ts:158-162) + ExtraRefCharacterContext (extra-refs.ts:52-56) The video-extras + shared-extras lookup types read the node default via these, not via ConnectedReference. Returned by both lookupCharacterBySlug adapters (FE video-prompt-assembly.ts:298-311, BE payload-builder.ts:940-948) and buildExtraRefCharacterContextLookup (payload-builder.ts:815).
reused identityLock (off/soft/strict) CharacterNodeData (nodes.ts:3675) No new field. Now also stamped onto ConnectedReference.identityLock ({enabled,text}) — which is already in connected-reference-schema.ts:69-71, so no schema change for lock.

Node UI — character-node.tsx (hybrid branch, gated on IMAGE_REFERENCE_FORMAT)

Resolution — one shared helper, applied to all six paths

Add a single helper (shared, e.g. reference-roles.ts):

resolveDefaultRole(defaultRole, defaultUsageMode, source):
  return defaultRole?.trim() || firstSightExtraRole(defaultUsageMode, source)

defaultRole is stored pre-sanitised ⇒ used verbatim (Custom survives). When absent, it falls back to the existing firstSightExtraRole (back-compat: face/pose/style pass through, else "person").

Role precedence per path:

Identity-lock mapping. A shared helper (in identity-lock.ts) maps the node identityLock mode → the per-reference lock shape, stamped onto the ref at every construction site:

off    → { enabled: false }                                       // no line
soft   → { enabled: true, text: getIdentityLockClause("soft") }   // mild
strict → { enabled: true, text: getIdentityLockClause("strict") } // strong

buildIdentityLockLine(ref, binding) is already called on all three image paths (:1051/:974/:355) and all three video paths (:615/:516/:339), so once the ref carries identityLock, the line emits automatically. The per-mention pill ~lock/~nolock still overrides via withForcedIdentityLock (its undefined/inherit path returns the ref unchanged, so the node default governs; true/false force on/off — identity-lock.ts:163-167).

Plumbing — stamp defaultRole + identityLock at every ref-construction site, read via the helpers

Shared (packages/shared/src/):

Frontend:

Backend (backend/src/):

Back-compat & behavior change

Testing

Registration / process

Out of scope