Super-admin: delete any board, ban a member (needs a design first) #196

Closed
opened 2026-09-18 17:12:51 +00:00 by Grandiras · 2 comments
Owner

Part of #190. This needs a design conversation before it needs code — it is filed so the open questions are written down, not so it gets picked up as is.

Decided already: the super-admin (Allowlist:Admins[]) can not open every board, but can delete any board and ban a member.

Open:

  • What does the super-admin see of a board they are not on? They have to be able to tell boards apart to act on a report. Suggested: name, its admins, member count, card count, created date — and nothing inside it. The name is content somebody typed; whether showing it counts as "access" is the actual question.
  • Deleting a board that holds cards. Today BoardService.DeleteAsync refuses (BoardNotEmptyException) and the FKs are Restrict all the way down, on the principle that deleting a board never deletes a card. A super-admin who cannot see the cards cannot empty the board first, so this has to cascade — and then so does a board admin deleting their own. Soft delete with a grace period is worth weighing: it is the only version with an undo.
  • What a ban is. Off the invite list is the mechanism that exists (MemberService, and the revalidating auth state provider ends their circuits within five minutes). What happens to what they made — their cards and comments on other people's boards, the boards they alone administer — is undecided.
  • A board whose last admin is gone. #192 refuses to remove the last admin, but a ban or a deleted account gets past that. Either the super-admin may appoint one without being able to read the board, or the longest-standing member inherits it.
Part of #190. **This needs a design conversation before it needs code** — it is filed so the open questions are written down, not so it gets picked up as is. Decided already: the super-admin (`Allowlist:Admins[]`) can *not* open every board, but can delete any board and ban a member. Open: - **What does the super-admin see of a board they are not on?** They have to be able to tell boards apart to act on a report. Suggested: name, its admins, member count, card count, created date — and nothing inside it. The name is content somebody typed; whether showing it counts as "access" is the actual question. - **Deleting a board that holds cards.** Today `BoardService.DeleteAsync` refuses (`BoardNotEmptyException`) and the FKs are `Restrict` all the way down, on the principle that deleting a board never deletes a card. A super-admin who cannot see the cards cannot empty the board first, so this has to cascade — and then so does a board admin deleting their own. Soft delete with a grace period is worth weighing: it is the only version with an undo. - **What a ban is.** Off the invite list is the mechanism that exists (`MemberService`, and the revalidating auth state provider ends their circuits within five minutes). What happens to what they made — their cards and comments on other people's boards, the boards they alone administer — is undecided. - **A board whose last admin is gone.** #192 refuses to remove the last admin, but a ban or a deleted account gets past that. Either the super-admin may appoint one without being able to read the board, or the longest-standing member inherits it.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/567); the reply will link the full session transcript. <!-- claude-run:comment --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-196/run-567.md) — updating every ~5 minutes while the task runs.
Collaborator

I've read the code behind all four questions. Here is my reply.


#196 — the design

No code and no branch: the issue asks for the argument, not the diff. Everything below is grounded in what is actually in main today; the reasons land in docs/ in the pull requests that implement them, not now, because a docs/ page describing unbuilt behaviour is the "confidently wrong" state CLAUDE.md warns about.

Four questions, four answers, then the schema and the work split. The short version:

Question Answer
What the super-admin sees of a board they are not on One board at a time, by id, never a list. Structure plus the name. Not the contents.
Deleting a board that holds cards Soft delete with a 30-day grace, for both roles, one mechanism. BoardNotEmptyException goes.
What a ban is A flag on the Members row, not the absence of one. Their work stays, under their name.
A board whose last admin is gone The longest-standing member inherits, automatically. The startup repair stops granting the super-admin.

1. What the super-admin sees of a board they are not on

Start from the fact the rest of the design has to be honest about: the super-admin holds the encryption key. They run the container, they have the psql and ENCRYPTION_KEY that Encryption/ reads. There is no version of this app in which a restricted board is secret from them. So the boundary between the super-admin and somebody else's board is not a security boundary and never was — it is an etiquette boundary, and what the app owes a restricted board is the absence of a screen that invites snooping, not secrecy it cannot deliver.

That reframing settles the question the issue calls "the actual question" — whether showing the name counts as access. It does count. It is allowed anyway, narrowly, and here is where the line falls:

  • There is no board index, ever. A page listing every board on the deployment with its name is exactly what Restricted exists to prevent, and #190 already decided it ("a restricted board appears in nobody's home page or switcher unless they are on it — the super-admin included"). Nothing here reopens that.
  • The surface is lookup by id: /admin/board/{id}, a single board's record. Not a list. You reach it by having been handed the id — which is what a report is: somebody pastes the link they are complaining about. That inverts the privacy question from "here is every private board" to "somebody showed me this one; tell me what it is".
  • The record carries the name, and everything else on it is structure. Name, visibility, created date, its admins (handle + face), member count, column count, card count, comment count, and the date of the most recent card change. Every one of those except the name is a count on a foreign key or a plaintext timestamp — this is the same budget BoardService.GetTilesAsync already runs on, for the same reason.
  • Nothing a member typed except the board's name. No column names, no tag names, no card titles, no comments. A report about a card quotes the card; the super-admin does not need to browse to it.
  • It never becomes a way in. BoardAccess.VisibleTo keeps having no admin bypass. /board/{id} still says board.denied to a super-admin who was never granted it.

Why the name is allowed and the contents are not

Three reasons, in the order they matter:

  1. A deletion whose target you cannot read is made blind, and deleting the wrong board is the unrecoverable failure here — worse than the leak, which is one string about a board somebody already reported.
  2. The name is already in the report. Refusing to show it does not stop the super-admin knowing it; it stops them checking it against the id they were given.
  3. It is one string per board acted on, and it cannot be enumerated. Casual reading is what the no-list rule forecloses.

The record page is logged

Opening /admin/board/{id} writes one structured line — who looked at which board, when — through the ordinary ILogger. No new table, no new screen. Say plainly in the docs that this is not tamper-proof and cannot be: the person it records owns the log. It is there so the ordinary case leaves a trail, not to defend against the operator.

Deliberately not doing: telling the board's admins that a deployment admin looked. Ponente has no in-app notifications and Members holds no email address (only AccessRequest does), so "tell the admins" is a whole new surface for one event. What the admins do get told is the thing that actually changes their board — see the next section: a moderation delete is a visible state on the board itself, with the date and who did it.


2. Deleting a board that holds cards

Today BoardService.DeleteAsync refuses (BoardNotEmptyException) and the FKs are Restrict from Board → BoardColumn → Card → (CardTag, CardAssignee, Comment), with Tag restricted too. The comment on the method says it: "it could not cascade even if it wanted to".

Decision: soft delete with a 30-day grace period, and it is the same mechanism for a board admin deleting their own board and for a super-admin deleting somebody else's. The issue is right that a super-admin who cannot see the cards cannot empty the board first, and right that the board admin's delete has to follow — one delete verb, two people allowed to press it.

What a delete does

Sets three plaintext columns on Board and nothing else:

DeletedAt          DateTime?   null = live
DeletedByDid       string?     who pressed it
DeletedAsModeration bool       a super-admin's delete, or the board's own admin's

Nothing is destroyed. Not one row is removed. Grants and BoardOrder rows stay — they are what restore needs.

The reason this design is cheap: the rule already lives in one expression

BoardAccess.VisibleTo is an Expression<Func<Board, bool>> and every board read in the app goes through it or through BoardService.GetVisibleAsync, which does. Adding b.DeletedAt == null to that one expression removes a deleted board from:

home tiles · the nav switcher · BoardPage · /calendar, /calendar/all and /calendar/board/{id} · both .ics endpoints · SearchService · the ForYouService strip · CardService's move-destination check · SetPinnedAsync

…in one line, and BoardAccessTests compiles the same expression, so the case is pinned without a fixture. That property is the strongest argument for doing it this way rather than with a separate IsDeleted filter per call site.

Two reads need their own clause because they deliberately do not use VisibleTo:

  • BoardService.GetAdministeredAsync — read by NavHeader (does this board wear a "Board settings" link) and by AdminRedirect. A deleted board must not be the board a bare /admin/tags substitutes to.
  • ResolveAsync's fall-through. It returns Denied for any board that exists but did not pass VisibleTo, which would tell a board's own admin that their just-deleted board is somebody else's. So BoardOutcome gains a fourth value, Deleted — the enum already documents having dropped a third one, so this is the shape it is used to. A viewer who would have passed VisibleTo but for DeletedAt gets Deleted; everybody else gets Missing, which is the truthful answer for them.

The board.deleted state is the audit trail

/board/{id} for somebody who could see it before renders a fifth empty state (data-outcome="deleted", beside missing and denied): "Deleted on 14 September. Recoverable until 14 October." A board admin also gets a Restore button.

That state is how a board's admins find out a deployment admin deleted their board — for free, on the thing it is about, with no notification system. A board vanishing with no explanation is indistinguishable from a bug; this one explains itself.

Who may restore:

Deleted by Restorable by
the board's own admin any admin of that board, or a super-admin
a super-admin (DeletedAsModeration) a super-admin only

That is what the boolean is for. A moderation delete the board's admins can undo with one click is not moderation, and inferring it from whether DeletedByDid is currently in Allowlist:Admins[] would give a different answer the day the environment changes — which is precisely the "it reads at runtime" property #192 built on purpose. Store the fact.

The purge

One BackgroundService beside MailSender, waking daily, destroying boards whose DeletedAt is older than 30 days — and that is the only place in the app that may destroy a card.

  • The FKs stay Restrict. The purge writes the delete order out by hand, in one transaction: CardTag/CardAssignee/Comment → Card → BoardColumn → Tag → BoardMember/BoardOrder → Board. Switching to Cascade would be less code and would delete the schema's own statement that nothing loses a card by accident. What protects work now is the grace period; the FK stays as the thing that makes the accident impossible, and exactly one method is allowed past it.
  • 30 days, a constant, not a setting. Longer than a holiday, the shape everybody already knows from a trash can. A Boards:GraceDays knob would ask every deployment to decide how long work stays recoverable, and every deployment would leave it at the default.
  • A server that never restarts still purges, which is why this is a timer and not a startup pass.

What changes for members

BoardNotEmptyException is deleted. A board admin deleting a board full of cards is now allowed, where it used to refuse and tell them to empty it first. That is the member-visible reversal and it needs its own line in Changelog.Unreleased, in both locales, plus a rewrite of settings.delete.hint to say what goes and for how long it comes back. settings.delete.cards (the TCount plural) goes with the exception.

This reverses the paragraph in docs/boards-and-permissions.md that says "Deleting a board never deletes a card" and the <remarks> on DeleteAsync that argues it. Both get rewritten with the new reason in the same PR: deleting a board still never destroys a card at the moment you press it — the guarantee moved from "it refuses" to "you have thirty days", which is strictly more than the old one bought, because the old one had no undo for the delete it did allow.


3. What a ban is

The mechanism that exists does not work

MemberService.RemoveAsync deletes the Members row. But Card.CreatedByDid, Comment.AuthorDid and CardAssignee.MemberDid are all FK Restrict to Member, so removal fails for anybody who has ever made a card, written a comment, or been assigned to one. The page already admits it — Members.razor catches DbUpdateException and shows admin.members.remove.error: "Can't remove: member is still assigned to a card or has authored cards."

So the mechanism the issue calls "off the invite list" works on exactly the people a ban is never for, and refuses the people it is. A ban cannot be a row deletion.

Decision: a ban is a flag on the row

BannedAt      DateTime?   null = not banned
BannedByDid   string?
BannedReason  string?     encrypted — content somebody typed

BannedReason gets the encryption converter in OnModelCreating and not an EncryptionMigrator.Targets entry: Targets is for pre-existing rows that could hold plaintext, and a new column has none.

Three reasons for a flag rather than a delete, in order:

  1. The row is the foreign key target of everything they made. Deleting it either fails (today) or destroys their work (with cascades). Neither is what a ban means.
  2. A ban has to be reversible. A ban made in anger, or on the wrong DID, that destroyed a member's work is not a mistake you can take back.
  3. The row is the name and face rendered on every card they wrote. Delete it and eight people's board renders bare DIDs where a name used to be — a worse outcome for the people who did nothing than for the person being punished.

What a ban actually does — one line, and everything downstream is already right

DbAllowlistService.ReloadAsync gains .Where(m => m.BannedAt == null) on its DID query. That snapshot is IsAllowed, and IsAllowed is:

  • the Allowlisted policy on every page, so every board-facing screen closes;
  • PersistingRevalidatingAuthStateProvider, which ends their live circuits inside five minutes;
  • CalendarFeedService.ResolveAsync, which already re-checks allowlist.IsAllowed(did) on every feed fetch — the comment there calls it "the check that makes removal stick" — so the banned member's .ics subscription stops on the next sync with no extra code.

That the existing revalidation and the feed-token path both fall out of one predicate is the reason to put the ban on the member row rather than inventing a second gate beside it.

What happens to what they made: nothing, and that is the decision

Their cards and comments stay, on the boards they are on, under their name. A board is a shared record of work. Deleting one member's cards out of eight people's board deletes the board's history to punish one person, and the people left with the holes did not do anything.

Where the record itself is the problem, the tool already exists and is better aimed: CardPermissions lets a board admin delete any card or any comment on their board. A ban removes the person; a board admin removes the thing. Two tools, two scopes, and the small one already works.

Their board grants stay too

A ban changes nothing on BoardMember. The allowlist is the gate that stops them; the grants are inert while the ban holds, and keeping them is what makes lifting a ban one click instead of a re-invitation across every board they were on.

Readers keep working; pickers narrow

MemberService.GetAllAsync keeps returning everybody. This is the same decision Member.UsesLocalProfile already made and it is written down in docs/orientation.md as one of the dozen surprises — "every reader is unaffected, which is why it is written this way". Six call sites read that list, and some of them use it to draw the face of an assignee who is already on a card. Filter it in the service and those faces become bare DIDs.

So the filter goes where a member is offered as a choice, not where they are read:

  • AssigneePicker's candidate list (BoardPage, Calendar, via AvailableMembers);
  • Components/Pages/Settings/BoardAccess.razor's grant picker;
  • Home.razor's new-board flow.

A banned member already on a card keeps their face and their name. They are just not offered to anybody again.

The front door has to close too, or the ban means nothing

  • MemberService.AddAsync against an existing banned row refuses — re-adding is not how a ban is lifted.
  • RequestsPanel.ApproveAsync refuses a banned DID before it calls AddAsync, and /request from one is declined.
  • A configured super-admin cannot be banned. IsAllowed short-circuits on _superAdmins from the config, so the button would do nothing; refuse it by name rather than let it lie.

The honest limit, and it belongs in the docs

A ban bans a DID, not a person. Somebody holding a PDS invite code can sign up at /signup and arrive as a new DID. The thing that actually controls the front door is the invite list, which is already the super-admin's — so the ban screen says so, next to the button, in one sentence: a banned member with an invite code can make a new account. Disable the code too. PdsGateway already has DisableInviteAsync and the invites panel already exposes it; the copy just has to point at it.


4. A board whose last admin is gone

The invariant is stated slightly wrong today

LastBoardAdminException guards "a board always has at least one admin" in SetAccessAsync and in RemoveAsync, and DatabaseInitializer.EnsureBoardAdminsAsync repairs it on boot. With a ban as a flag, the grant row survives the ban — so the invariant is never technically broken, and the board is unrunnable anyway. The invariant that matters is:

a board always has at least one admin who can sign in.

That is a one-word change with three consequences:

  • EnsureBoardAdminsAsync's query becomes !b.Members.Any(m => m.Role == Admin && m.Member.BannedAt == null).
  • The last-admin checks in SetAccessAsync and MemberService.RemoveAsync must count only non-banned admins — otherwise a banned admin "counts", and you can take the last reachable one off a board without being refused. That is a real bug the ban introduces if this is missed.
  • Banning triggers succession.

The rule: the longest-standing member inherits

On ban, in the same transaction, for every board where the banned member held the only non-banned admin grant: promote the longest-standing remaining grant holder — BoardMember.GrantedAt ascending, then DID ordinal so it is deterministic.

  • GrantedAt means what this needs it to mean. SetAccessAsync already preserves it across re-saves on purpose — "re-saving the list is not re-inviting everybody, and the history a grant row carries is the only record of who was given a board when."
  • It is the only non-arbitrary ordering the data supports. There is no "last active" on a grant row, and adding one to answer a rare question is a write per visit for a fact nobody renders — the trade MarkOpenedAsync already argues.
  • It matches how a group actually works: the person who has been on the board longest is the one most likely to know what it is for.

The rule is pure — a list of grants in, one DID or nothing out — so it goes in a static Services/BoardSuccession.cs in the shape of CardMove and BoardFilter, and BoardSuccessionTests pins the cases (nobody left, one member, a tie on GrantedAt, every remaining member already banned) in xunit instead of at a board apiece in Playwright.

A board with nobody to inherit is left alone

A private board of one, whose one member is banned, has no successor.

It stays. Admin-less, unreachable, untouched. Not soft-deleted by the ban, and — this is the part that changes today's code — not handed to the super-admin.

  • Destroying it is the one thing that cannot be undone, and a private board of one contains only that person's own work, visible to nobody. There is no benefit to weigh against it.
  • Lifting the ban brings it back exactly as it was. That is the whole argument.
  • If the deployment genuinely wants it gone, a human decides: the super-admin looks it up at /admin/board/{id} and soft-deletes it there, with the 30-day undo. The policy call stays with a person and the default is "do nothing irreversible".

EnsureBoardAdminsAsync stops granting super-admins — this is a back door today

Right now, any board left without an admin grant gets the configured super-admin DIDs granted on the next boot, which hands them full read access to a restricted board they were never given. It exists as a repair for states that predate #192's refusal, and it is rare today. Under a ban regime it would become the routine outcome — quietly undoing the decision #190 made explicitly.

So the startup repair runs the succession rule instead: promote the longest-standing non-banned member, and where there is none, leave the board admin-less and log it. It does not grant anybody.

Compatibility: grants already minted by the old repair are real rows and stay — dropping them could orphan a board that now depends on one. What changes is that no new ones are minted, and a super-admin who only holds a board because of the old repair can take their own grant off from the board's access page, which they already have.

Remove and ban stay two different verbs

MemberService.RemoveAsync keeps refusing with LastBoardAdminException. Removal is a tidy-up — somebody left the group — and it is right to make you hand the board over first. A ban is the thing that cannot wait for a negotiation, and it is the one that promotes. That asymmetry is the reason both verbs exist, and it goes in docs/auth.md in those words.


Schema, all of it

Table Column Type Encrypted Why
Boards DeletedAt timestamp? no A timestamp, not content. BoardAccess.VisibleTo has to be able to ask it in SQL — the same rule as BoardMember.
Boards DeletedByDid text? no A DID stays plaintext, like every other DID here.
Boards DeletedAsModeration bool no A switch this app chose the values of, like BoardRole.
Members BannedAt timestamp? no Read by DbAllowlistService.ReloadAsync in SQL on every reload.
Members BannedByDid text? no As above.
Members BannedReason text? yes Somebody typed it. Converter in OnModelCreating; no EncryptionMigrator.Targets entry — no pre-existing rows can hold it.

No index needed at this scale; DeletedAt is one more predicate on a table with tens of rows.

Two migrations, one per step below. Both satisfy #190's rule — nobody's access changes on deploy day and the previous release still runs against the database: every column is nullable or defaults to the current behaviour, and nothing is dropped.

Work split — a proposal, not filed

I have deliberately not opened implementation issues. The issue asks for a conversation and the split should survive it. If these read right, say so and I will file them.

  1. Soft delete, restore, purge — board admins only. Boards.DeletedAt and friends, the clause in VisibleTo, BoardOutcome.Deleted, the board.deleted state, the purge service, BoardNotEmptyException removed. Ships alone and is worth having alone: it gives every board admin an undo they do not have today. Playwright goes in boards.spec.js, which already creates and deletes its own boards.
  2. Ban — Members.BannedAt, the one line in ReloadAsync, the front-door refusals, the picker filters, BoardSuccession + its unit tests, the corrected last-admin counts, EnsureBoardAdminsAsync stops granting super-admins, and the ban/lift controls on /admin/members. Needs nothing from step 1. New tests/e2e/moderation.spec.js — the fixture is a super-admin, two ordinary members and a shared board, which is genuinely its own and does not belong bolted onto permissions.spec.js.
  3. The board record page — /admin/board/{id} behind the Admin policy, the structure read, the log line, and the super-admin's moderation delete on top of step 1's mechanism. Needs both.

Docs each step outdates, in its own PR: boards-and-permissions.md (the delete paragraph, which this reverses, and Who may run a board), auth.md (the allowlist, the ban, remove-versus-ban), data.md (the six columns and why five are plaintext, and the purge as the one place a card dies), ui.md/i18n.md for the strings, testing.md for the new spec.

One thing I did not decide

Whether a board admin's own delete should be restorable by them alone or by any admin of that board. I have written "any admin of that board" above, on the grounds that the role is one job and BoardPermissions gives one answer for all of it — but a board with three admins where one deletes it and another restores it is a disagreement the app would be silently arbitrating, and that is a group's problem rather than a schema's. It is one line either way and it does not block anything; I would rather it was said out loud than defaulted.


Filed separately: two paragraphs in docs/ that went stale across #193/#194 and now state the reverse of what the code does — found while reading for this, neither in scope here.


⏱ 6m 52s · 37 turns · tokens: 119.2k in / 30.6k out (+2377.8k cache-read)
📜 Full session transcript
→ Filed follow-up issue #210: Two docs paragraphs predate the home page and still describe the old model

I've read the code behind all four questions. Here is my reply. --- # #196 — the design No code and no branch: the issue asks for the argument, not the diff. Everything below is grounded in what is actually in `main` today; the reasons land in `docs/` in the pull requests that implement them, not now, because a `docs/` page describing unbuilt behaviour is the "confidently wrong" state `CLAUDE.md` warns about. Four questions, four answers, then the schema and the work split. The short version: | Question | Answer | |---|---| | What the super-admin sees of a board they are not on | One board at a time, by id, never a list. Structure plus the name. Not the contents. | | Deleting a board that holds cards | Soft delete with a 30-day grace, for **both** roles, one mechanism. `BoardNotEmptyException` goes. | | What a ban is | A flag on the `Members` row, not the absence of one. Their work stays, under their name. | | A board whose last admin is gone | The longest-standing member inherits, automatically. The startup repair stops granting the super-admin. | --- ## 1. What the super-admin sees of a board they are not on **Start from the fact the rest of the design has to be honest about: the super-admin holds the encryption key.** They run the container, they have the `psql` and `ENCRYPTION_KEY` that `Encryption/` reads. There is no version of this app in which a restricted board is secret *from them*. So the boundary between the super-admin and somebody else's board is not a security boundary and never was — it is an etiquette boundary, and what the app owes a restricted board is **the absence of a screen that invites snooping**, not secrecy it cannot deliver. That reframing settles the question the issue calls "the actual question" — whether showing the name counts as access. It does count. It is allowed anyway, narrowly, and here is where the line falls: - **There is no board index, ever.** A page listing every board on the deployment with its name is exactly what `Restricted` exists to prevent, and #190 already decided it ("a restricted board appears in nobody's home page or switcher unless they are on it — the super-admin included"). Nothing here reopens that. - **The surface is lookup by id: `/admin/board/{id}`, a single board's record.** Not a list. You reach it by having been handed the id — which is what a report *is*: somebody pastes the link they are complaining about. That inverts the privacy question from "here is every private board" to "somebody showed me this one; tell me what it is". - **The record carries the name, and everything else on it is structure.** Name, visibility, created date, its admins (handle + face), member count, column count, card count, comment count, and the date of the most recent card change. Every one of those except the name is a count on a foreign key or a plaintext timestamp — this is the same budget `BoardService.GetTilesAsync` already runs on, for the same reason. - **Nothing a member typed except the board's name.** No column names, no tag names, no card titles, no comments. A report about a card quotes the card; the super-admin does not need to browse to it. - **It never becomes a way in.** `BoardAccess.VisibleTo` keeps having no admin bypass. `/board/{id}` still says `board.denied` to a super-admin who was never granted it. ### Why the name is allowed and the contents are not Three reasons, in the order they matter: 1. **A deletion whose target you cannot read is made blind**, and deleting the wrong board is the unrecoverable failure here — worse than the leak, which is one string about a board somebody already reported. 2. **The name is already in the report.** Refusing to show it does not stop the super-admin knowing it; it stops them *checking* it against the id they were given. 3. **It is one string per board acted on, and it cannot be enumerated.** Casual reading is what the no-list rule forecloses. ### The record page is logged Opening `/admin/board/{id}` writes one structured line — who looked at which board, when — through the ordinary `ILogger`. No new table, no new screen. Say plainly in the docs that this is not tamper-proof and cannot be: the person it records owns the log. It is there so the *ordinary* case leaves a trail, not to defend against the operator. **Deliberately not doing:** telling the board's admins that a deployment admin looked. Ponente has no in-app notifications and `Members` holds no email address (only `AccessRequest` does), so "tell the admins" is a whole new surface for one event. What the admins *do* get told is the thing that actually changes their board — see the next section: a moderation delete is a visible state on the board itself, with the date and who did it. --- ## 2. Deleting a board that holds cards Today `BoardService.DeleteAsync` refuses (`BoardNotEmptyException`) and the FKs are `Restrict` from `Board` → `BoardColumn` → `Card` → (`CardTag`, `CardAssignee`, `Comment`), with `Tag` restricted too. The comment on the method says it: *"it could not cascade even if it wanted to"*. **Decision: soft delete with a 30-day grace period, and it is the same mechanism for a board admin deleting their own board and for a super-admin deleting somebody else's.** The issue is right that a super-admin who cannot see the cards cannot empty the board first, and right that the board admin's delete has to follow — one delete verb, two people allowed to press it. ### What a delete does Sets three plaintext columns on `Board` and nothing else: ``` DeletedAt DateTime? null = live DeletedByDid string? who pressed it DeletedAsModeration bool a super-admin's delete, or the board's own admin's ``` Nothing is destroyed. Not one row is removed. Grants and `BoardOrder` rows stay — they are what restore needs. ### The reason this design is cheap: the rule already lives in one expression `BoardAccess.VisibleTo` is an `Expression<Func<Board, bool>>` and every board read in the app goes through it or through `BoardService.GetVisibleAsync`, which does. Adding `b.DeletedAt == null` to that one expression removes a deleted board from: home tiles · the nav switcher · `BoardPage` · `/calendar`, `/calendar/all` and `/calendar/board/{id}` · **both** `.ics` endpoints · `SearchService` · the `ForYouService` strip · `CardService`'s move-destination check · `SetPinnedAsync` …in one line, and `BoardAccessTests` compiles the same expression, so the case is pinned without a fixture. That property is the strongest argument for doing it this way rather than with a separate `IsDeleted` filter per call site. Two reads need their own clause because they deliberately do not use `VisibleTo`: - **`BoardService.GetAdministeredAsync`** — read by `NavHeader` (does this board wear a "Board settings" link) and by `AdminRedirect`. A deleted board must not be the board a bare `/admin/tags` substitutes to. - **`ResolveAsync`'s fall-through.** It returns `Denied` for any board that exists but did not pass `VisibleTo`, which would tell a board's own admin that their just-deleted board is somebody else's. So `BoardOutcome` gains a fourth value, `Deleted` — the enum already documents having dropped a third one, so this is the shape it is used to. A viewer who *would* have passed `VisibleTo` but for `DeletedAt` gets `Deleted`; everybody else gets `Missing`, which is the truthful answer for them. ### The `board.deleted` state is the audit trail `/board/{id}` for somebody who could see it before renders a fifth empty state (`data-outcome="deleted"`, beside `missing` and `denied`): *"Deleted on 14 September. Recoverable until 14 October."* A board admin also gets a **Restore** button. That state is how a board's admins find out a deployment admin deleted their board — for free, on the thing it is about, with no notification system. A board vanishing with no explanation is indistinguishable from a bug; this one explains itself. **Who may restore:** | Deleted by | Restorable by | |---|---| | the board's own admin | any admin of that board, or a super-admin | | a super-admin (`DeletedAsModeration`) | a super-admin only | That is what the boolean is for. A moderation delete the board's admins can undo with one click is not moderation, and inferring it from whether `DeletedByDid` is currently in `Allowlist:Admins[]` would give a different answer the day the environment changes — which is precisely the "it reads at runtime" property #192 built on purpose. Store the fact. ### The purge One `BackgroundService` beside `MailSender`, waking daily, destroying boards whose `DeletedAt` is older than 30 days — and that is **the only place in the app that may destroy a card.** - **The FKs stay `Restrict`.** The purge writes the delete order out by hand, in one transaction: `CardTag`/`CardAssignee`/`Comment` → `Card` → `BoardColumn` → `Tag` → `BoardMember`/`BoardOrder` → `Board`. Switching to `Cascade` would be less code and would delete the schema's own statement that nothing loses a card by accident. What protects work now is the grace period; the FK stays as the thing that makes the accident impossible, and exactly one method is allowed past it. - **30 days, a constant, not a setting.** Longer than a holiday, the shape everybody already knows from a trash can. A `Boards:GraceDays` knob would ask every deployment to decide how long work stays recoverable, and every deployment would leave it at the default. - **A server that never restarts still purges**, which is why this is a timer and not a startup pass. ### What changes for members **`BoardNotEmptyException` is deleted.** A board admin deleting a board full of cards is now allowed, where it used to refuse and tell them to empty it first. That is the member-visible reversal and it needs its own line in `Changelog.Unreleased`, in both locales, plus a rewrite of `settings.delete.hint` to say what goes and for how long it comes back. `settings.delete.cards` (the `TCount` plural) goes with the exception. This reverses the paragraph in `docs/boards-and-permissions.md` that says *"Deleting a board never deletes a card"* and the `<remarks>` on `DeleteAsync` that argues it. Both get rewritten with the new reason in the same PR: deleting a board **still** never destroys a card *at the moment you press it* — the guarantee moved from "it refuses" to "you have thirty days", which is strictly more than the old one bought, because the old one had no undo for the delete it did allow. --- ## 3. What a ban is ### The mechanism that exists does not work `MemberService.RemoveAsync` deletes the `Members` row. But `Card.CreatedByDid`, `Comment.AuthorDid` and `CardAssignee.MemberDid` are all FK `Restrict` to `Member`, so **removal fails for anybody who has ever made a card, written a comment, or been assigned to one.** The page already admits it — `Members.razor` catches `DbUpdateException` and shows `admin.members.remove.error`: *"Can't remove: member is still assigned to a card or has authored cards."* So the mechanism the issue calls "off the invite list" works on exactly the people a ban is never for, and refuses the people it is. A ban cannot be a row deletion. ### Decision: a ban is a flag on the row ``` BannedAt DateTime? null = not banned BannedByDid string? BannedReason string? encrypted — content somebody typed ``` `BannedReason` gets the encryption converter in `OnModelCreating` and **not** an `EncryptionMigrator.Targets` entry: `Targets` is for pre-existing rows that could hold plaintext, and a new column has none. Three reasons for a flag rather than a delete, in order: 1. **The row is the foreign key target of everything they made.** Deleting it either fails (today) or destroys their work (with cascades). Neither is what a ban means. 2. **A ban has to be reversible.** A ban made in anger, or on the wrong DID, that destroyed a member's work is not a mistake you can take back. 3. **The row is the name and face rendered on every card they wrote.** Delete it and eight people's board renders bare DIDs where a name used to be — a worse outcome for the people who did nothing than for the person being punished. ### What a ban actually does — one line, and everything downstream is already right `DbAllowlistService.ReloadAsync` gains `.Where(m => m.BannedAt == null)` on its DID query. That snapshot is `IsAllowed`, and `IsAllowed` is: - the `Allowlisted` policy on every page, so every board-facing screen closes; - `PersistingRevalidatingAuthStateProvider`, which ends their live circuits inside five minutes; - **`CalendarFeedService.ResolveAsync`**, which already re-checks `allowlist.IsAllowed(did)` on every feed fetch — the comment there calls it *"the check that makes removal stick"* — so the banned member's `.ics` subscription stops on the next sync with no extra code. That the existing revalidation and the feed-token path both fall out of one predicate is the reason to put the ban on the member row rather than inventing a second gate beside it. ### What happens to what they made: nothing, and that is the decision **Their cards and comments stay, on the boards they are on, under their name.** A board is a shared record of work. Deleting one member's cards out of eight people's board deletes the board's history to punish one person, and the people left with the holes did not do anything. Where the *record itself* is the problem, the tool already exists and is better aimed: `CardPermissions` lets a board admin delete any card or any comment on their board. A ban removes the person; a board admin removes the thing. Two tools, two scopes, and the small one already works. ### Their board grants stay too A ban changes nothing on `BoardMember`. The allowlist is the gate that stops them; the grants are inert while the ban holds, and keeping them is what makes lifting a ban one click instead of a re-invitation across every board they were on. ### Readers keep working; pickers narrow `MemberService.GetAllAsync` keeps returning everybody. This is the same decision `Member.UsesLocalProfile` already made and it is written down in `docs/orientation.md` as one of the dozen surprises — *"every reader is unaffected, which is why it is written this way"*. Six call sites read that list, and some of them use it to draw the face of an assignee who is already on a card. Filter it in the service and those faces become bare DIDs. So the filter goes where a member is **offered as a choice**, not where they are read: - `AssigneePicker`'s candidate list (`BoardPage`, `Calendar`, via `AvailableMembers`); - `Components/Pages/Settings/BoardAccess.razor`'s grant picker; - `Home.razor`'s new-board flow. A banned member already on a card keeps their face and their name. They are just not offered to anybody again. ### The front door has to close too, or the ban means nothing - `MemberService.AddAsync` against an existing banned row refuses — re-adding is not how a ban is lifted. - `RequestsPanel.ApproveAsync` refuses a banned DID before it calls `AddAsync`, and `/request` from one is declined. - A configured super-admin cannot be banned. `IsAllowed` short-circuits on `_superAdmins` from the config, so the button would do nothing; refuse it by name rather than let it lie. ### The honest limit, and it belongs in the docs **A ban bans a DID, not a person.** Somebody holding a PDS invite code can sign up at `/signup` and arrive as a new DID. The thing that actually controls the front door is the invite list, which is already the super-admin's — so the ban screen says so, next to the button, in one sentence: *a banned member with an invite code can make a new account. Disable the code too.* `PdsGateway` already has `DisableInviteAsync` and the invites panel already exposes it; the copy just has to point at it. --- ## 4. A board whose last admin is gone ### The invariant is stated slightly wrong today `LastBoardAdminException` guards "a board always has at least one admin" in `SetAccessAsync` and in `RemoveAsync`, and `DatabaseInitializer.EnsureBoardAdminsAsync` repairs it on boot. With a ban as a flag, **the grant row survives the ban** — so the invariant is never technically broken, and the board is unrunnable anyway. The invariant that matters is: > **a board always has at least one admin who can sign in.** That is a one-word change with three consequences: - `EnsureBoardAdminsAsync`'s query becomes `!b.Members.Any(m => m.Role == Admin && m.Member.BannedAt == null)`. - The last-admin checks in `SetAccessAsync` and `MemberService.RemoveAsync` must count only non-banned admins — otherwise a banned admin "counts", and you can take the last reachable one off a board without being refused. That is a real bug the ban introduces if this is missed. - Banning triggers succession. ### The rule: the longest-standing member inherits On ban, in the same transaction, for every board where the banned member held the only non-banned admin grant: promote the **longest-standing remaining grant holder** — `BoardMember.GrantedAt` ascending, then DID ordinal so it is deterministic. - **`GrantedAt` means what this needs it to mean.** `SetAccessAsync` already preserves it across re-saves on purpose — *"re-saving the list is not re-inviting everybody, and the history a grant row carries is the only record of who was given a board when."* - It is the only non-arbitrary ordering the data supports. There is no "last active" on a grant row, and adding one to answer a rare question is a write per visit for a fact nobody renders — the trade `MarkOpenedAsync` already argues. - It matches how a group actually works: the person who has been on the board longest is the one most likely to know what it is for. The rule is pure — a list of grants in, one DID or nothing out — so it goes in a static `Services/BoardSuccession.cs` in the shape of `CardMove` and `BoardFilter`, and `BoardSuccessionTests` pins the cases (nobody left, one member, a tie on `GrantedAt`, every remaining member already banned) in xunit instead of at a board apiece in Playwright. ### A board with nobody to inherit is left alone A private board of one, whose one member is banned, has no successor. **It stays. Admin-less, unreachable, untouched.** Not soft-deleted by the ban, and — this is the part that changes today's code — **not handed to the super-admin.** - Destroying it is the one thing that cannot be undone, and a private board of one contains only that person's own work, visible to nobody. There is no benefit to weigh against it. - Lifting the ban brings it back exactly as it was. That is the whole argument. - If the deployment genuinely wants it gone, a human decides: the super-admin looks it up at `/admin/board/{id}` and soft-deletes it there, with the 30-day undo. The policy call stays with a person and the default is "do nothing irreversible". ### `EnsureBoardAdminsAsync` stops granting super-admins — this is a back door today Right now, any board left without an admin grant gets **the configured super-admin DIDs granted on the next boot**, which hands them full read access to a restricted board they were never given. It exists as a repair for states that predate #192's refusal, and it is rare today. Under a ban regime it would become the routine outcome — quietly undoing the decision #190 made explicitly. So the startup repair runs the succession rule instead: promote the longest-standing non-banned member, and where there is none, **leave the board admin-less and log it**. It does not grant anybody. Compatibility: grants already minted by the old repair are real rows and stay — dropping them could orphan a board that now depends on one. What changes is that no new ones are minted, and a super-admin who only holds a board because of the old repair can take their own grant off from the board's access page, which they already have. ### Remove and ban stay two different verbs `MemberService.RemoveAsync` **keeps refusing** with `LastBoardAdminException`. Removal is a tidy-up — somebody left the group — and it is right to make you hand the board over first. A ban is the thing that cannot wait for a negotiation, and it is the one that promotes. That asymmetry is the reason both verbs exist, and it goes in `docs/auth.md` in those words. --- ## Schema, all of it | Table | Column | Type | Encrypted | Why | |---|---|---|---|---| | `Boards` | `DeletedAt` | `timestamp?` | no | A timestamp, not content. `BoardAccess.VisibleTo` has to be able to ask it in SQL — the same rule as `BoardMember`. | | `Boards` | `DeletedByDid` | `text?` | no | A DID stays plaintext, like every other DID here. | | `Boards` | `DeletedAsModeration` | `bool` | no | A switch this app chose the values of, like `BoardRole`. | | `Members` | `BannedAt` | `timestamp?` | no | Read by `DbAllowlistService.ReloadAsync` in SQL on every reload. | | `Members` | `BannedByDid` | `text?` | no | As above. | | `Members` | `BannedReason` | `text?` | **yes** | Somebody typed it. Converter in `OnModelCreating`; no `EncryptionMigrator.Targets` entry — no pre-existing rows can hold it. | No index needed at this scale; `DeletedAt` is one more predicate on a table with tens of rows. Two migrations, one per step below. Both satisfy #190's rule — **nobody's access changes on deploy day and the previous release still runs against the database**: every column is nullable or defaults to the current behaviour, and nothing is dropped. ## Work split — a proposal, not filed I have deliberately **not** opened implementation issues. The issue asks for a conversation and the split should survive it. If these read right, say so and I will file them. 1. **Soft delete, restore, purge** — board admins only. `Boards.DeletedAt` and friends, the clause in `VisibleTo`, `BoardOutcome.Deleted`, the `board.deleted` state, the purge service, `BoardNotEmptyException` removed. Ships alone and is worth having alone: it gives every board admin an undo they do not have today. Playwright goes in `boards.spec.js`, which already creates and deletes its own boards. 2. **Ban** — `Members.BannedAt`, the one line in `ReloadAsync`, the front-door refusals, the picker filters, `BoardSuccession` + its unit tests, the corrected last-admin counts, `EnsureBoardAdminsAsync` stops granting super-admins, and the ban/lift controls on `/admin/members`. Needs nothing from step 1. New `tests/e2e/moderation.spec.js` — the fixture is a super-admin, two ordinary members and a shared board, which is genuinely its own and does not belong bolted onto `permissions.spec.js`. 3. **The board record page** — `/admin/board/{id}` behind the `Admin` policy, the structure read, the log line, and the super-admin's moderation delete on top of step 1's mechanism. Needs both. Docs each step outdates, in its own PR: `boards-and-permissions.md` (the delete paragraph, which this reverses, and *Who may run a board*), `auth.md` (the allowlist, the ban, remove-versus-ban), `data.md` (the six columns and why five are plaintext, and the purge as the one place a card dies), `ui.md`/`i18n.md` for the strings, `testing.md` for the new spec. ## One thing I did not decide **Whether a board admin's own delete should be restorable by them alone or by any admin of that board.** I have written "any admin of that board" above, on the grounds that the role is one job and `BoardPermissions` gives one answer for all of it — but a board with three admins where one deletes it and another restores it is a disagreement the app would be silently arbitrating, and that is a group's problem rather than a schema's. It is one line either way and it does not block anything; I would rather it was said out loud than defaulted. --- **Filed separately:** two paragraphs in `docs/` that went stale across #193/#194 and now state the reverse of what the code does — found while reading for this, neither in scope here. --- ⏱ 6m 52s · 37 turns · tokens: 119.2k in / 30.6k out (+2377.8k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-196/run-567.md) → Filed follow-up issue [#210](https://git.grandiras.net/Grandiras/Ponente/issues/210): Two docs paragraphs predate the home page and still describe the old model
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente#196
No description provided.