feat(console): rank who took part in the most of a guild's meetings - #109
Open
TheMeinerLP wants to merge 14 commits into
Open
feat(console): rank who took part in the most of a guild's meetings#109TheMeinerLP wants to merge 14 commits into
TheMeinerLP wants to merge 14 commits into
Conversation
The sidebar was one flat list, which put "Settings" next to "Calendar" as though changing the bot's runtime configuration and looking at your own meetings were the same kind of act. They are not: one is a use of your own data, the other is an operation on a guild on behalf of everyone in it. Two named groups say which hat the reader is wearing before they click anything, and the Admin View has room to grow -- a user directory, a queue overview and reporting are all administrative and all homeless in a flat list of four. The section flag hides the heading as well as its entries. A visible "Admin View" with nothing under it would announce the existence of a section to exactly the person who may not have it. Hiding remains a courtesy and never a control: every administrative endpoint checks administrator status itself, and the worst this can do is offer somebody a page that then refuses them. Collapsed, the groups are a rule between two runs of icons rather than a heading -- but each group carries its name on the group element in both modes, so the boundary is announced rather than merely drawn. Somebody who cannot see the rule is exactly the person who most needs telling that the next icon acts on the whole guild. The settings page moves to /admin/bot-settings and /settings becomes a permanent redirect. The old address is in browser histories and in whatever anybody pasted into a chat, and a 404 there teaches people the console loses pages.
…sent Until now a consent could end in two ways: the person ended it themselves with `/consent revoke`, or an administrator bumped `policy_version`, which ends everybody's at once. Neither answers the case in between -- somebody left the team, or asked in a channel rather than in a slash command, or is no longer somebody this guild should be recording. What an administrator reaches for otherwise is removing the Discord role by hand. That stops the recording and leaves `revoked_at` NULL, so `/consent status` still reports consent active and re-adding the role silently resumes recording somebody who never re-consented. This closes that gap from the side that lasts. What a revocation from the console is, exactly ---------------------------------------------- It stamps `revoked_at` on the stored consent record, through `ConsentRepository.record_revocation` -- the same statement `/consent revoke` makes, unwrapped, because a second definition of what a revocation is would agree with the first right up until one of them changed. It does **not** remove the Discord role. The API process holds no Discord token and never will (Spec 13.2): a process that can decrypt every recording ever made is not one to also give the ability to act as the bot. That is enough to stop the recording -- consent is two layers, and the stored record is checked on every frame through a five second cache, and it is the layer that exists precisely because the role can be bypassed by anyone with administrator permissions in Discord. So a revocation takes effect within five seconds, mid-session. What it leaves behind is a role the person still holds, which is visible in Discord and misleading if nobody says so. It is also **not a delete**. Nothing already recorded is touched; `/audio purge` does that, it is admin-gated, and it is deliberately a separate act. Withdrawing consent is a decision about the future, and erasing a meeting a team has already read is not the same decision. Every row therefore carries how many recordings of that person the guild still holds, so the distinction is on the screen rather than in a document nobody opens. The rest -------- `active` is computed server-side from `sturnus.domain.consent`, never derived in the browser. A grant names the policy version it was given under, so a guild that has moved its `policy_version` on has consents with no `revoked_at` and no force -- and a console working that out for itself would be a second implementation of `is_consent_active`. Authorisation is in `ConsoleConsentDirectory`, not in the handlers: there is no method on it that can be called without saying who is asking, and both answer `None` for "no such guild" and for "you do not administer it" alike. The handlers answer 404 to both. A 403 would confirm that a list of who agreed to be recorded exists here, to somebody just established as having no business with it. `consent.revoked_at` records that a revocation happened and never who performed it, so `console.consent_revoked` at WARNING carries `requested_by` beside `discord_user_id` and is the only place that pair is ever written down.
The Admin View's second entry, and the interface half of the endpoints in the previous commit. It lists everyone a guild holds a consent record for, what policy version they agreed to, when, and whether that consent is still in force. Three things the page says out loud, because all three are ways an administrator could otherwise reasonably believe something untrue: - **The Discord role is not removed.** The API holds no Discord token, by design, and cannot change anybody's roles. Recording still stops within about five seconds, mid-session, because the stored record is what is checked on every frame -- but the role stays, and somebody who wants it gone has to remove it in Discord. - **Nothing already recorded is deleted.** The count of recordings that still hold that person's audio is on their row, and the confirmation names it. Erasing them is `/audio purge`, a separate act. - **It is written to the audit log**: who withdrew whose consent, and when. So the control is a two-step confirmation naming all three, the same pattern Bot Settings uses for a value that invalidates consent. A row that is already withdrawn gets no control at all, only the sentence saying when it happened -- the page never offers an action it knows will fail. `active` is never re-derived in the browser. A grant names the policy version it was given under, so a guild that has moved on has consents with no `revoked_at` and no force; the badge trusts the server's verdict and `revoked_at` only distinguishes "withdrawn" from "the policy version they agreed to is no longer current". Those are different facts about a person and they lead to different conversations. Every decision -- ordering, wording, whether a revoke is offered, how a refusal reads -- is in `~/utils/consents` and tested there. The ordering ranks by what the reader can still do: consent in force, then lapsed by policy version, then already withdrawn, then named people before nameless ones. Names are compared lowercased rather than through `localeCompare`, which sorts by the runtime's locale and would make the server and the browser disagree -- a hydration reshuffle for a list somebody is reading.
The runbook half of the console's consent revocation. Three things an administrator would otherwise reasonably believe and which are not true: that it removes the Discord role, that it deletes what was already recorded, and that a consent with no `revoked_at` is necessarily in force. It also writes down where the audit trail actually lives. `consent` has no column naming who performed a revocation -- `/consent revoke` never needed one, because the only person who could run it was the subject -- so for a third-party revocation the log line is the whole trail, and its retention is the trail's retention. That is a fact somebody should learn from the runbook rather than from needing it.
…l owes
The console could already say where one session's transcription had got
to, if you already knew which session to ask about. What it could not
answer is the question an administrator actually starts from: is anything
stuck, and if so, where. `/queue status` answers the first half in
Discord; this answers both, with the sessions the totals are made of.
`GET /api/guilds/{guild_id}/queue`.
Everything below the authorisation is
`sturnus.infrastructure.db.requeue`, and the totals are `load_status`
unchanged -- the same function the `/queue status` command reads. A
console that counted the jobs itself would be a second definition of "how
much work is outstanding", and the two would agree until one of them
changed. The one new read is `load_active_sessions`, which lives beside
the others rather than in the console adapter, so both callers keep asking
the same questions of the same place.
**Unfinished is deliberately two conditions.** Anything that is not
`documented` is obviously unfinished. But a session can reach `documented`
with a `dead` job in it -- the document is written once every job is
terminal, and `dead` is terminal -- so a speaker whose transcription
failed permanently would vanish from the queue view at exactly the moment
somebody needs to notice them. A dead job keeps its session on the list
however finished the session claims to be. An `open` session with no jobs
at all is included for the opposite reason: it is a recording happening
right now.
Two numbers travel with a caveat rather than as bare facts, because both
would otherwise be read as something they are not:
- `running_past_lease` is derived from an *assumed* lease, and the lease
that applies is the worker's `job_lease_seconds`, which this process
cannot see. So `lease_seconds` is sent with the count and the console
names it -- the same caveat `/queue status` prints in Discord.
- `oldest_pending_session_ended_at` is dated by the session's end, because
`transcription_job` has no enqueue timestamp at all. That is within
seconds of when the jobs were created, which answers "has this been
sitting here for hours?" and nothing more precise. It is not the age of
a re-queued job, which keeps its session's original end.
The list is cut at twenty and says when it was cut. A guild that has been
broken for a month has hundreds, and a page showing twenty that does not
admit to cutting reads as "there are twenty".
404 for a guild this person does not administer, and the same 404 for one
that does not exist: the list names when a guild met and in which channel.
The Admin View's third entry, and the interface half of the guild-wide queue endpoint. It shows the four lifecycle counts, the three figures that mean somebody has to act, and the unfinished sessions the counts are made of. Rows are ordered by how much attention they need, then newest first. "Needs a person" -- a dead job, or a closed session with nothing queued and no document -- comes above work that is merely moving, and a dead job outranks even a written document: a session that reached `documented` with a permanently failed speaker in it looks finished from every other angle, and this page is the only place anybody finds out otherwise. Whether a session is still recording is decided by `ended_at` rather than by its status string, so an unrecognised status cannot turn a live meeting into an unexplained row of zeros. Newest first within every rank, including the stuck ones. Somebody opens this page because a team has just said "this morning's meeting has no protocol", and a list that counts backwards in one rank and forwards in another cannot be scanned at all. How long the backlog has been there is what the oldest-pending line says in words, without reordering anything. Three numbers carry their caveats in the sentence rather than in a footnote, because all three would otherwise be read as something they are not: the expired-lease count is measured against the lease this process assumed and not the worker's real one, the oldest-pending figure is dated by the session's end because `transcription_job` records no enqueue time at all, and the list is a window on the backlog rather than its size. There is no re-queue control here. Each row links to the recording page, which has one -- so whether a redo is safe stays a single decision in `plan_requeue` rather than becoming two. Polling follows `RequeuePanel`'s shape: a chain of timeouts rather than an interval, cleared on unmount, client-only. It runs while `pending` or `running` is non-zero and ignores `dead`, because a dead job never changes on its own and polling for it would wait forever on news that cannot come.
Which sessions appear on it and why "unfinished" is two conditions, plus the three figures that must not be read at face value: the expired-lease count is measured against a lease this process assumed rather than the worker's real one, the oldest-pending figure is dated by the session's end because `transcription_job` records no enqueue time at all, and the list is a window on the backlog rather than its size. Also says where the closed-and-undocumented count leads: it is what the worker's document-retry sweep normally clears, so one that stays put across refreshes is a document write that is failing, and §5 is where to look next.
The loop was a chain of timeouts cleared in `onBeforeUnmount`, which reads as correct and is not: `clearTimeout` cannot stop a timer that has **already fired**, and the continuation after the `await` inside it installs a fresh one that nothing is left to cancel. Navigating away during the seconds a read is in flight therefore left the page making a guild-wide database read every five seconds for the life of the tab, invisibly. The same defect was found independently in `RequeuePanel` (#108), whose polling shape this page copied. Fixing it in two places by hand would leave a third copy waiting to be written, so the loop is extracted to `startQueuePolling` in `~/utils/queue` and the page keeps only the decision of when to start one. `alive` is checked **after every await**, which is exactly where an unmount happens without the resuming code being told, and `stop()` clears that flag as well as the pending timer. Either half alone is the bug. A failed round now ends the loop rather than retrying. The page has an error to show and a refresh control to try again with; a loop that retried on its own would turn one bad second into a request every five for as long as the tab is open. Extracted rather than fixed in place because the property is one no build, type check or render can show, and a page component cannot be asked about it without a Nuxt runtime around it. As a plain function it is seven tests with fake timers -- including the one that matters, where the stop lands *during* a request rather than between two.
An administrator configuring Sturnus has had no way to tell whether it is
working out. How often does this guild actually meet, how long do the
meetings run, how many of them produced a protocol, is the transcription
measuring anything at all. Every one of those is answerable from rows the
system already writes, and none of them was answerable from anywhere in
the product.
`GET /api/guilds/{guild_id}/report`.
**This reports on a guild and never on a person, and that is a boundary
rather than a gap.**
A report over meetings can be written two ways. One says how much a team
recorded, how long its meetings run and how many people are usually in
them -- facts about the guild, which is what somebody deciding whether the
bot is configured sensibly needs. The other ranks named individuals by how
many meetings they attended and how long they spoke, which is a different
artifact entirely: in Germany and the EU a per-person readout of
attendance and speaking time is a means of monitoring performance and
conduct, and introducing one is a matter for a works council rather than
something a console adds because the columns happen to be there.
So the identities never leave the statement. The adapter counts
participant rows -- per session, and `COUNT(DISTINCT ...)` across the
guild -- and `sturnus.console.reporting` is handed counts rather than
people. A report module holding a list of who attended is one edit away
from ranking them; there is a test asserting the payload contains no
`discord_user_id` and no `display_name`, and it is there to fail loudly
rather than to pass quietly.
Two things the numbers would otherwise be misread as:
- **Months are cut in the guild's own `timezone`**, the same calendar the
protocols are written in, and the payload names which zone was used. A
meeting that opened at 00:30 Berlin time belongs to the month the people
in it think it does; UTC would file it under the previous one and
disagree with the timestamps printed in the protocol of that very
meeting. An unusable value falls back to UTC, the same fallback the
worker applies, and the named zone is how anyone finds out it happened.
- **Null is not zero.** `speech_seconds` is nullable, `SUM` skips nulls
silently, and every job predating the measurement columns has one. The
number of skipped tracks travels beside the total, so a large one reads
as "this figure describes part of what was recorded" rather than as "the
guild was quiet". For the same reason an average over nothing is `None`
and not `0`: a guild with no closed sessions has no average length, and
reporting one as zero states that its meetings are instantaneous.
404 for a guild this person does not administer, and the same 404 for one
that does not exist: the report describes when a team meets and how often.
The Admin View's fourth entry, and the interface half of the guild report. Headline figures, then a month-by-month breakdown, then the caveats the figures cannot be read correctly without. Two of those caveats are the reason the page is not just a row of numbers: - **An absent figure renders as absent, never as zero.** A guild with no finished session has no average meeting length, and a `0` there states that its meetings are instantaneous. The API sends `null` for exactly that reason and the page keeps the distinction. - **"Unmeasured tracks" is not silence.** `speech_seconds` is nullable, `SUM` skips nulls, and every recording predating the measurement columns has one -- so a large count of unmeasured tracks means the speaking figure describes part of what was recorded, not that the server was quiet. The page says so in words rather than leaving it to be inferred from two numbers side by side. Months are labelled in the guild's own timezone and the page names which one, because a meeting that opened at 00:30 Berlin time belongs to the month the people in it think it does. Months with no sessions are absent from the payload rather than zero-filled, and how the breakdown handles that gap is a decision made once, in `~/utils/reporting`, where it is tested. The page is framed as how a server uses Sturnus, and it names nobody. That framing matches what the endpoint sends: the report is about a guild, not about the people in it.
…ely omits Two numbers on that page are easy to read as something they are not, and both are written down here: months are cut in the guild's own calendar rather than in UTC or the reader's -- deliberately a different choice from the personal calendar view, and the reason for the difference is stated -- and "unmeasured tracks" is the size of the hole in the speaking total rather than evidence that the guild was quiet. The section also records why the report stops where it does. It is about a guild and never about a named person, and that is a decision rather than an omission: a per-person readout of meeting attendance and speaking time is subject to co-determination, and the rows for it exist. Somebody reading the runbook should find that written down rather than conclude the feature was simply forgotten.
`GET /api/guilds/{guild_id}/report/participation`.
**This is the one thing Sturnus produces that names other people and ranks
them, and whether to have it is not a technical decision.**
Everything else in the console is about the person reading it or about a
guild in aggregate; `sturnus.console.reporting` stops short of this on
purpose and says so. This is where that line is crossed, deliberately, in
a change that can be reverted on its own.
An ordered list of colleagues by meeting attendance and speaking time is a
`technische Einrichtung, die dazu bestimmt ist, das Verhalten oder die
Leistung der Arbeitnehmer zu überwachen` -- BetrVG §87(1)(6) -- and is
subject to co-determination in a German workplace with a works council,
whether or not anybody intended it as a monitoring tool. The GDPR half is
the same point from the other side: the recordings were collected so a
protocol could be written, and an attendance ranking serves a further
purpose from the same data.
Neither makes it wrong to have. Both make it a decision for the people who
run the guild rather than a field that appeared in a payload. So the
change is shaped to keep that decision visible and reversible:
- **Its own port, endpoint, adapter and module.** Not a method on
`GuildReports`, which promises in its own docstring to name nobody and
would have that made false. Not having this is one revert rather than an
audit of a shared response shape, and `/report` keeps working without it.
- **Every read emits an audit line**, which no other read in this console
does. `console.participation_read` carries `guild_id`, `requested_by`
and how many people were in the answer -- and never who they were,
because the list is the thing under discussion and copying it into a
retained searchable store would be making a second copy of it. "Who
looked at the attendance ranking, and when" is the first question
anybody reviewing this arrangement will ask, and there was no answer to
it before this line.
- **It reports attendance and speaking time and nothing further.** No
words spoken, no punctuality, no share of talk, nothing per meeting.
Each of those is another purpose and would need deciding again.
- **It is ordered by meetings attended and never by speaking time.** "Was
present most often" and "talked the most" are different statements about
a colleague, and only one of them was asked for. The order is total --
meetings, then name, then id -- so a list of named people does not
appear to reshuffle itself between two page loads, and somebody the
system has no name for sorts after everyone it does.
The session count travels with the ranking because a rank means nothing
without it: "in eleven meetings" is one claim out of twelve and quite
another out of four hundred.
Null is not zero here either: somebody whose recordings predate the
measurement columns has no speaking total rather than a zero, and the
count of unmeasured tracks says how large the hole is.
The interface half of the participation endpoint, added to the Reporting page rather than given a page of its own -- it belongs next to the figures it is the exception to. **It loads only when the reader asks.** A separate request behind a control that says what pressing it will do. Somebody who opens Reporting to check whether transcription is keeping up must not silently generate an audit line saying they looked at a ranking of their colleagues, and the control's own note says as much. Three standing notes sit above that control rather than above the list, where they are read before the decision instead of after it. They say what the ranking is, that every read is written to the audit log -- which server, who looked, when, and never who was in the list -- that the recordings were made to write meetings up and counting attendance is a further purpose than that, and that such a facility is subject to co-determination under BetrVG §87(1)(6). And the one that matters most: being present in more meetings is not a measure of contribution, and speaking time even less so. A meeting held in a room, in a call elsewhere, in a channel Sturnus does not watch, or with somebody who has not consented, is invisible here -- so a low place is not evidence of anything and a high one is not either. The presentation carries the same restraint as the wording. Speaking time is a sentence under the attendance rather than a column, because a column of durations is ranked by the eye whether or not anybody sorted it. Rows are drawn identically: no bars, no medals, no emphasis on the first. Equal attendance shares a place, labelled as shared, because the tie-break is alphabetical and is about their names rather than about them. Every figure is "n of m" and never a percentage -- a percentage travels into a performance review without its denominator. `REPORT_SCOPE_NOTE` is amended here, not in the report change: it promised there was no per-person breakdown behind it, and that sentence stops being true at this commit. A scope note caught lying in one claim earns no belief in the others.
The runbook section for the one thing Sturnus produces that names other people and ranks them. It states the co-determination question plainly -- BetrVG §87(1)(6), and the GDPR purpose-limitation half of the same point -- and says outright that neither makes the feature wrong to have, only that the decision belongs to the people who run the guild. The rest is what an operator needs in order to act on that: which LogQL query answers "who read the ranking, and when", why the log line carries a count rather than the list, what the ranking deliberately does not report, and that declining it is one revert which leaves the rest of the reporting page working.
TheMeinerLP
force-pushed
the
feat/console-guild-report
branch
from
August 22, 2026 22:03
65c6588 to
2f8ae0b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the attendance ranking to the Reporting page: the people a guild has recorded, ordered by how many of its meetings they were in.
Stacked on #107. Read the section below before merging this one.
🛑 This is the decision I want you to take deliberately, not me
You asked for "who was in the most sessions". This is it, built — and split out of #107 so that you can have the aggregate report without it.
An ordered list of colleagues by meeting attendance and speaking time is a
technische Einrichtung, die dazu bestimmt ist, das Verhalten oder die Leistung der Arbeitnehmer zu überwachen. In a German workplace with a works council that is mitbestimmungspflichtig under BetrVG §87(1)(6), whether or not anybody intended it as a monitoring tool. The GDPR half is the same point from the other side: the recordings were collected so a protocol could be written, and counting attendance is a further purpose served from the same data.Neither makes it wrong to have. Both make it a decision for the people who run the guild rather than a field that appeared in a payload. If OneLiteFeather has a works council, or may have one, agree this with them before merging. If the answer is no, close this PR — #107 works without it and the rest of the console is untouched.
How the change is shaped so that "no" stays cheap
/report/participation,ParticipationReports,sturnus.console.participation) — not a method onGuildReports, which promises in its own docstring to name nobody. Declining this is one revert, not an audit of a shared response shape.console.participation_readat INFO withguild_id,requested_byand how many people were in the answer — and never who they were, because the list is the thing under discussion and copying it into a retained searchable store would be making a second copy of it. No other read in this console is logged; the asymmetry is the point.What it reports, and what it refuses to
The page states, above the reveal control, that being present in more meetings is not a measure of contribution and speaking time even less so — meetings held in a room, elsewhere, in an unwatched channel, or with somebody who has not consented are invisible here, so a low place is not evidence of anything and a high one is not either.
REPORT_SCOPE_NOTEis amended in this PR rather than in #107: it promised there was no per-person breakdown behind it, and that stops being true here. A scope note caught lying in one claim earns no belief in the others.Checks
pytest -q1411 passed (27 new) ·vitest run589 passed (58 new) ·ruff·mypy·typecheck·lint·build— all clean.