feat(backend): carry a character's heard-as aliases through export and import (#580) #635

Merged
claude-bot merged 1 commit from feat/580-heard-as-export-import into main 2026-09-12 23:17:18 +00:00
Collaborator

Moving a campaign to another Quest Board silently dropped the list that keeps its characters' names spelled right.

The gap

heard_as (#579) lives on campaign_characters, which an import does not create — a restored campaign becomes unlinked_members stubs, each holding one character in flat columns with nowhere to put a list. The export never carried the field either. So a GM who exported and imported elsewhere lost every alias, and the transcript regressed to the mis-heard spelling with nothing anywhere to say why.

The change

  • Export schema 9 → 10: each character object carries heard_as.
  • unlinked_members.character_heard_as (JSONB, NOT NULL, default [], raw-SQL migration b1c2d3e4f5a6) holds it until the stub is claimed. Defaulted rather than nullable, unlike the "not measured" JSONB columns on summarisation_runs: a stub from an older export genuinely has no aliases, so [] is the true value and every reader can skip the None branch.
  • Claiming the stub passes them to create_character, which already accepted the argument.
  • Versions 2–9 still import, and get [] — a self-hoster's backup does not get rewritten when the schema moves.

Aliases from an export are normalised rather than trusted: data.json is hand-editable text from another instance, and an alias with a stray space silently never matches once the pipeline builds its alias map — a feature that does nothing rather than an error. A list the rules reject refuses the import naming the character, the way an invalid member role does, rather than dropping the aliases and restoring a campaign whose transcripts quietly spell a name wrong.

A hole this opened, closed in the same PR

Writing the test for it is what found it. _reject_alias_collisions consulted campaign_characters only — reasonable when stubs held no aliases. Once they do, an imported campaign would accept "Wyatt" as a misspelling of Idani while a stub called Wyatt waited to be claimed, and the alias map would then rewrite every line mentioning Wyatt: exactly the harm that check exists to prevent, reached by a route that did not exist before.

It now also consults unclaimed stubs' character names. A stub's own aliases are deliberately not consulted — they cannot produce a snap until the stub is claimed, and that claim goes through this check.

Found and filed, not fixed here

The reverse direction is a pre-existing gap that needs no import at all: create Idani with heard_as: ["Wyatt"] (allowed, no Wyatt yet), then create Wyatt (allowed, the check never looks at Idani's aliases). Filed as #634 rather than widened into this PR — it predates stubs carrying anything and deserves its own decision about existing campaigns already in that state.

Tests

Eight new tests in test_campaigns.py: export carries the aliases; the stub keeps them; claiming restores them to the real character; a pre-v10 export imports with []; a messy-but-valid list is normalised; an invalid one refuses with the character named; claiming a stub whose alias is a pending stub's name is refused with the stub left intact; plus the migration's head and raw-DDL pins.

The test_the_relabel_migration_is_the_one_head test is demoted to the "still on the one chain" form the file already uses for superseded heads, and the head pin moves to sit beside this change.

Backend suite: 3466 passed, 13 skipped. ruff check / ruff format --check clean under CI's pinned 0.4.4.

Closes #580

🤖 Generated with Claude Code

Moving a campaign to another Quest Board silently dropped the list that keeps its characters' names spelled right. ## The gap `heard_as` (#579) lives on `campaign_characters`, which an import does not create — a restored campaign becomes `unlinked_members` stubs, each holding one character in flat columns with nowhere to put a list. The export never carried the field either. So a GM who exported and imported elsewhere lost every alias, and the transcript regressed to the mis-heard spelling with nothing anywhere to say why. ## The change - **Export schema 9 → 10**: each character object carries `heard_as`. - **`unlinked_members.character_heard_as`** (JSONB, NOT NULL, default `[]`, raw-SQL migration `b1c2d3e4f5a6`) holds it until the stub is claimed. Defaulted rather than nullable, unlike the "not measured" JSONB columns on `summarisation_runs`: a stub from an older export genuinely *has* no aliases, so `[]` is the true value and every reader can skip the None branch. - **Claiming the stub** passes them to `create_character`, which already accepted the argument. - **Versions 2–9 still import**, and get `[]` — a self-hoster's backup does not get rewritten when the schema moves. Aliases from an export are **normalised rather than trusted**: `data.json` is hand-editable text from another instance, and an alias with a stray space silently never matches once the pipeline builds its alias map — a feature that does nothing rather than an error. A list the rules reject **refuses the import naming the character**, the way an invalid member role does, rather than dropping the aliases and restoring a campaign whose transcripts quietly spell a name wrong. ## A hole this opened, closed in the same PR Writing the test for it is what found it. `_reject_alias_collisions` consulted `campaign_characters` only — reasonable when stubs held no aliases. Once they do, an imported campaign would accept "Wyatt" as a misspelling of Idani while a stub called *Wyatt* waited to be claimed, and the alias map would then rewrite every line mentioning Wyatt: exactly the harm that check exists to prevent, reached by a route that did not exist before. It now also consults unclaimed stubs' character names. A stub's *own* aliases are deliberately **not** consulted — they cannot produce a snap until the stub is claimed, and that claim goes through this check. ## Found and filed, not fixed here The **reverse direction** is a pre-existing gap that needs no import at all: create *Idani* with `heard_as: ["Wyatt"]` (allowed, no Wyatt yet), then create *Wyatt* (allowed, the check never looks at Idani's aliases). Filed as **#634** rather than widened into this PR — it predates stubs carrying anything and deserves its own decision about existing campaigns already in that state. ## Tests Eight new tests in `test_campaigns.py`: export carries the aliases; the stub keeps them; claiming restores them to the real character; a pre-v10 export imports with `[]`; a messy-but-valid list is normalised; an invalid one refuses with the character named; claiming a stub whose alias is a pending stub's name is refused with the stub left intact; plus the migration's head and raw-DDL pins. The `test_the_relabel_migration_is_the_one_head` test is demoted to the "still on the one chain" form the file already uses for superseded heads, and the head pin moves to sit beside this change. Backend suite: `3466 passed, 13 skipped`. `ruff check` / `ruff format --check` clean under CI's pinned 0.4.4. Closes #580 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(backend): carry a character's heard-as aliases through export and import (#580)
All checks were successful
CI / Docker image build (pull_request) Successful in 26s
CI / Bot/backend version sync (pull_request) Successful in 36s
CI / Backend lint (ruff) (pull_request) Successful in 40s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 57s
CI / Bot tests and audit (pull_request) Successful in 1m50s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m17s
CI / Backend migration, tests, and audit (pull_request) Successful in 8m20s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 19m29s
08b869bda9
`heard_as` (#579) is the list of ways the transcriber actually mishears a
character's name — "Vic" for Viquilonto — and it is what stops a session's
transcript spelling a player's character wrong all evening. It lives on
`campaign_characters`, which an import does not create: a restored campaign
becomes `unlinked_members` stubs, each holding one character in flat columns
with nowhere to put a list. So a GM who exported a campaign and imported it
elsewhere lost every alias, and the transcript quietly regressed with nothing
to say why.

- Export schema 9 → 10: each character object carries `heard_as`.
- `unlinked_members.character_heard_as` (JSONB, NOT NULL, default `[]`, raw-SQL
  migration `b1c2d3e4f5a6`) holds it until the stub is claimed. Defaulted
  rather than nullable because a stub from an older export genuinely has no
  aliases rather than unmeasured ones.
- Claiming the stub passes them to `create_character`, which already took the
  argument.
- The importer still accepts 2-9, where the field cannot exist; those stubs get
  `[]`.

Aliases from an export are normalised rather than trusted — `data.json` is
hand-editable text from another instance, and an alias with a stray space
silently never matches once the pipeline builds its alias map. A list the rules
reject refuses the import naming the character, the way an invalid member role
does, rather than dropping the aliases and restoring a campaign whose
transcripts quietly spell a name wrong.

`_reject_alias_collisions` now also consults unclaimed stubs' character names.
Without that, this change opened a route into the exact state the rule exists
to prevent: an imported campaign would accept "Wyatt" as a misspelling of Idani
while a stub called Wyatt waited to be claimed, and the alias map would then
rewrite every line mentioning Wyatt. A stub's own aliases are deliberately not
consulted — they cannot snap until the stub is claimed, and that claim goes
through this check.

The reverse direction (a character's *name* colliding with another character's
existing alias) is a pre-existing gap reachable with no import at all, and is
filed as #634.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/580-heard-as-export-import 2026-09-12 23:17:18 +00:00
Sign in to join this conversation.
No description provided.