Lane 3: the members' server — who a GM can find, and stepping down (D56, D57, #729) #764

Merged
claude-bot merged 2 commits from build/lane3-members-server into main 2026-09-23 02:40:10 +00:00
Collaborator

Lane 3's second PR: the two server changes CAMP-8 needs. Backend only.

Who "Add someone" can find (D56, IS-382)

search_non_members ILIKEd display names across every non-system account on the instance. Harmless on a private server; on a hosted one it is a way for any GM to enumerate strangers, which is what D56 is about:

Who Add someone and Attach to an account can find is the instance's choice — anyone on the server, or only people the GM has played with — and the line under the box says which.

A new member_search_scope setting (a key in the existing JSON store, no migration), with played_with meaning "shares at least one campaign with the searcher".

The default is anyone, which is today's behaviour. Same rule the retention cap followed in #763: a setting that appears must not change behaviour until somebody sets it. Defaulting to played_with would have silently narrowed what every existing self-hosted GM can find, on upgrade day, with nothing said. Lane 9 draws the admin control that changes it.

An archived shared campaign counts — you did play together. A soft-deleted one does not, matching #405 everywhere else.

The scope is on CampaignResponse, because the line sits under an empty box and so cannot come from the search response.

Each result says how the person relates

The drawing makes this load-bearing, printing a sentence under every name. The response carries the fact, not the sentence:

"shared_campaign": { "id": "…", "name": "Low Tide", "is_one_shot": true, "is_archived": false } | null

Corrected during review. This first carried current, meaning "both are members now" — which was always true and could never be anything else: CampaignMember rows are hard-deleted on leave and removal, so there is no record of a campaign either person has left, and every match the query can find is by construction one they are both still in. A field that cannot be false is worse than no field, because somebody will eventually branch on it.

It was also not the distinction the page needs. The drawing prints "Played with you in 'Low Tide', a one-shot" against "Plays in 'The Bramble Court', with you" — past tense or present. What decides that is whether the campaign is finished, and a one-shot is not the only way to be finished; an archived campaign is too. So is_archived replaced it, with a test for the case that motivated it.

The owner may step down as GM (D57, IS-383)

The owner can step down as GM and keep the campaign, but only while another member is GM; with no other GM the control is absent.

This deliberately narrows one #644 behaviour, and only one

#644 removed exactly this class of guard on purpose, and says so in four places: the last GM can leave, the owner may remove the last GM, "a campaign with no GM is a legitimate state". So the guard is scoped as tightly as it can be — the owner, changing their own role, away from GM, when no other member is GM — and everything else is untouched.

One existing test asserted the behaviour D57 changes (test_the_last_gm_can_be_demoted, where the owner demotes themselves as sole GM). It is now test_the_owner_cannot_step_down_as_the_only_gm, asserting the 400 and that the role is unchanged. That is a decided reversal, not a regression — flagged here so nobody reading #644 later mistakes it for one.

The three #644 behaviours the guard must not touch each keep a test proving they still work: the last GM can leave; the owner can remove a GM, even the last one; and demoting somebody else is unaffected. All three pass unmodified.

The UI hides the control when there is no other GM, so the refusal is a backstop — but it has to exist, because the page is not the only caller.

Checks

Ruff 0.4.4 clean (format --check and check). Full backend suite 3993 passed, 13 skipped; the two touched files run 69 passed after the is_archived correction. No migration; no frontend file touched; ownership offer/accept untouched, as D57 requires.

🤖 Generated with Claude Code

Lane 3's second PR: the two server changes CAMP-8 needs. Backend only. ## Who "Add someone" can find (D56, `IS-382`) `search_non_members` ILIKEd display names across **every** non-system account on the instance. Harmless on a private server; on a hosted one it is a way for any GM to enumerate strangers, which is what D56 is about: > Who **Add someone** and **Attach to an account** can find is the **instance's** choice — anyone on the server, or only people the GM has played with — and the line under the box says which. A new `member_search_scope` setting (a key in the existing JSON store, **no migration**), with `played_with` meaning "shares at least one campaign with the searcher". **The default is `anyone`, which is today's behaviour.** Same rule the retention cap followed in #763: a setting that appears must not change behaviour until somebody sets it. Defaulting to `played_with` would have silently narrowed what every existing self-hosted GM can find, on upgrade day, with nothing said. Lane 9 draws the admin control that changes it. An **archived** shared campaign counts — you did play together. A **soft-deleted** one does not, matching #405 everywhere else. The scope is on `CampaignResponse`, because the line sits under an *empty* box and so cannot come from the search response. ### Each result says how the person relates The drawing makes this load-bearing, printing a sentence under every name. The response carries the fact, not the sentence: ```json "shared_campaign": { "id": "…", "name": "Low Tide", "is_one_shot": true, "is_archived": false } | null ``` **Corrected during review.** This first carried `current`, meaning "both are members now" — which was **always true and could never be anything else**: `CampaignMember` rows are hard-deleted on leave and removal, so there is no record of a campaign either person has left, and every match the query can find is by construction one they are both still in. A field that cannot be false is worse than no field, because somebody will eventually branch on it. It was also not the distinction the page needs. The drawing prints *"Played with you in 'Low Tide', a one-shot"* against *"Plays in 'The Bramble Court', with you"* — past tense or present. What decides that is whether the campaign is **finished**, and a one-shot is not the only way to be finished; an archived campaign is too. So `is_archived` replaced it, with a test for the case that motivated it. ## The owner may step down as GM (D57, `IS-383`) > The owner can **step down as GM** and keep the campaign, but only while another member is GM; with no other GM the control is absent. ### This deliberately narrows one #644 behaviour, and only one **#644 removed exactly this class of guard on purpose**, and says so in four places: the last GM *can* leave, the owner *may* remove the last GM, "a campaign with no GM is a legitimate state". So the guard is scoped as tightly as it can be — **the owner, changing their own role, away from GM, when no other member is GM** — and everything else is untouched. One existing test asserted the behaviour D57 changes (`test_the_last_gm_can_be_demoted`, where the owner demotes themselves as sole GM). It is now `test_the_owner_cannot_step_down_as_the_only_gm`, asserting the 400 and that the role is unchanged. **That is a decided reversal, not a regression** — flagged here so nobody reading #644 later mistakes it for one. The three #644 behaviours the guard must not touch each keep a test proving they still work: the last GM can **leave**; the owner can **remove** a GM, even the last one; and **demoting somebody else** is unaffected. All three pass unmodified. The UI hides the control when there is no other GM, so the refusal is a backstop — but it has to exist, because the page is not the only caller. ## Checks Ruff 0.4.4 clean (`format --check` and `check`). Full backend suite **3993 passed, 13 skipped**; the two touched files run **69 passed** after the `is_archived` correction. No migration; no frontend file touched; ownership offer/accept untouched, as D57 requires. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Two independent CAMP-8 decisions, both about who may do what to whom.

D56 (IS-382): who "Add someone" / "Attach to an account" can find is now the
instance's choice, not always every non-system account on the server. A new
member_search_scope app_setting ("anyone" | "played_with") gates
search_non_members; the default is "anyone", matching today's unscoped
behaviour, so no existing self-hosted GM's search narrows on upgrade day with
nothing said — the same rule D24's retention cap followed one PR ago. The
setter is deliberately not built here; it belongs to lane 9 (ADMIN). Each
result now also carries the fact behind the relationship line the page prints
under a name: a shared_campaign object (id, name, is_one_shot, current) or
null, chosen by preferring a campaign both are still in, then the most
recently created. member_search_scope also rides on CampaignResponse,
read-only, because the line explaining the scope sits under an empty search
box, before anything is typed.

D57 (IS-383): the owner may step down as GM and keep the campaign, but only
while another member is GM. #644 deliberately removed this whole class of
guard, so the new check in ownership_service.set_member_role is scoped as
narrowly as possible — only the owner, changing their own role, away from GM,
with nobody else holding it — leaving leaving (including as the last GM),
demoting someone else, and the owner removing the last GM exactly as #644
left them. Tests cover all three explicitly so nobody reads this as a
reversion.

No migration: both settings and facts are read from the existing app_settings
JSON store and campaign_members rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(backend): a shared campaign says whether it is finished, not whether it is current (D56, #729)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 51s
CI / Backend lint (ruff) (pull_request) Successful in 25s
CI / Docker image build (pull_request) Successful in 27s
CI / Bot tests and audit (pull_request) Successful in 1m49s
CI / Feedback site tests (pull_request) Successful in 39s
CI / Bot/backend version sync (pull_request) Successful in 42s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m41s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 5m27s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m43s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 16m23s
28c9ba425d
`SharedCampaignRef` carried `current`, meaning "both are members now". It was
always true and could never be anything else: `CampaignMember` rows are hard
deleted on leave and on removal, so there is no record of a campaign either
person has left, and every match the query can find is by construction one
they are both still in. A field that cannot be false is worse than no field —
somebody will branch on it.

And it was not the distinction the page needs. The drawing prints "Played with
you in 'Low Tide', a one-shot" against "Plays in 'The Bramble Court', with
you": past tense or present. What decides that is whether the campaign is
finished, and a one-shot is not the only way to be finished — an archived
campaign is too, however it was set up.

So `current` goes and `is_archived` takes its place, which is the fact the
sentence actually turns on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.