Zuständigkeit auf Fristen und Termine — assignee field, plus "meine Fristen" everywhere it should exist #161

Open
opened 2026-07-30 08:13:02 +00:00 by mAi · 2 comments
Collaborator

m, 2026-07-30, answering the #160 calendar question:

"I did mean per-user feed but we still need specific assignees for specific deadlines, too, in case teams want to assign someone specifically."

The gap

paliad.deadlines and paliad.appointments have no assignee field. The only per-user column is created_by — whoever typed the row in, which is not the same person as whoever is responsible for it.

Measured while designing #160 (cronus, 2026-07-29): there is no assignee, owner or responsible column on either table.

Why it is bigger than a column

The same missing concept blocks three separate things:

  1. Team assignment — m's actual ask: a team decides who is responsible for a specific deadline.
  2. "Meine Fristen" on the dashboard — cannot be built today, because "mine" can only mean "rows I typed".
  3. A my_deadlines calendar feed scope — deliberately kept out of #160 for this reason. #160 ships the per-user feed (which already works) and the hierarchy scopes; a feed scoped to my deadlines waits on this issue.

Scope

  • Column on deadlines and on appointments, nullable — an unassigned deadline is the normal case and must not be forced to a person.
  • UI to set and clear it, on the matter page and wherever a deadline is edited.
  • The dashboard's "meine Fristen" reading, once the field exists.
  • Decide explicitly what an assignee means for visibility: assigning someone who cannot see the matter is the same shape as the escalation-contact question m ruled on the same day — escalation without visibility makes no sense, and it should be flagged. Assignment without visibility is the same defect and should be flagged the same way rather than silently producing a deadline its owner cannot open.

Out of scope

Calendar feed scopes — that is #160.


Filed by paliad/head from m's ruling of 2026-07-30. The reminder scanner's audience predicate already reaches beyond can_see_project via escalation_contact_id, so read docs/findings-rls-s6-preflight-rehearsal-2026-07-29.md §9 before deciding how assignment interacts with visibility.

m, 2026-07-30, answering the #160 calendar question: > "I did mean per-user feed but we still need specific assignees for specific deadlines, too, in case teams want to assign someone specifically." ## The gap `paliad.deadlines` and `paliad.appointments` have **no assignee field**. The only per-user column is `created_by` — whoever typed the row in, which is not the same person as whoever is responsible for it. Measured while designing #160 (cronus, 2026-07-29): there is no assignee, owner or responsible column on either table. ## Why it is bigger than a column The same missing concept blocks three separate things: 1. **Team assignment** — m's actual ask: a team decides who is responsible for a specific deadline. 2. **"Meine Fristen"** on the dashboard — cannot be built today, because "mine" can only mean "rows I typed". 3. **A `my_deadlines` calendar feed scope** — deliberately kept out of #160 for this reason. #160 ships the per-user feed (which already works) and the hierarchy scopes; a feed scoped to *my* deadlines waits on this issue. ## Scope - Column on `deadlines` and on `appointments`, nullable — an unassigned deadline is the normal case and must not be forced to a person. - UI to set and clear it, on the matter page and wherever a deadline is edited. - The dashboard's "meine Fristen" reading, once the field exists. - Decide explicitly what an assignee means for **visibility**: assigning someone who cannot see the matter is the same shape as the escalation-contact question m ruled on the same day — *escalation without visibility makes no sense, and it should be flagged.* Assignment without visibility is the same defect and should be flagged the same way rather than silently producing a deadline its owner cannot open. ## Out of scope Calendar feed scopes — that is #160. --- *Filed by paliad/head from m's ruling of 2026-07-30. The reminder scanner's audience predicate already reaches beyond `can_see_project` via `escalation_contact_id`, so read `docs/findings-rls-s6-preflight-rehearsal-2026-07-29.md` §9 before deciding how assignment interacts with visibility.*
mAi self-assigned this 2026-07-30 08:13:02 +00:00
Author
Collaborator

The visibility half of this issue already has its core — reuse it, do not rebuild it

m's escalation ruling of the same day is implemented on mai/brunel/escalation-visibility-flag (beb133b, design at docs/design-escalation-visibility-flag-2026-07-30.md). It was built to serve this issue too, so the assignee work should add an enumerator and nothing else.

What is already thereinternal/services/visibility_mismatch_service.go:

  • CanSeeProjects(ctx, userID, projectIDs) → map[uuid]bool — the core. Answers "can this user see these matters" for any user, from any context.
  • VisibilityMismatch carries a relation label rather than being named after escalation. RelationEscalationContact exists; RelationAssignee is the one this issue adds.
  • The /admin/escalation-visibility page and its count badge render whatever relations the list contains, so an assignee row appears there with no new page.
  • No migration, nothing stored. The set is computed live, so no invalidation path is needed when a team changes.

What this issue supplies: the pairs. Escalation reads them from users.escalation_contact_id × the owner's pending deadlines' projects; assignment reads them from deadlines.assignee_id / appointments.assignee_id × that row's project.

Three things that will bite if they are re-derived rather than reused — each is a measured property, not a preference:

  1. Ask paliad.can_see_project AS the user, via db.WithActingUser. Do not write the predicate in Go. internal/services/visibility.go already carries a mirror of it and that mirror drifted from the function for two months over mig 055's partner-unit branch. A check whose job is to report a disagreement must not be a third implementation that can disagree with both.

  2. Do not implement the check as "run the query as them and see whether rows come back." That reports zero under today's permissive prod, because the bypassing role returns every row. It would go live at the S6 flip — dark for exactly the window the flag exists to cover. TestVisibilityMismatch_SameUnderBothRoles requires the identical answer under the bypassing role and under paliad_app, and that implementation fails it while passing everything else.

  3. FROM unnest($1::uuid[]), never FROM paliad.projects. projects is itself gated by can_see_project, so an invisible project yields no row and "this user cannot see it" collapses into "it does not exist".

The one thing this issue must decide for itself: what an assignee who cannot see the matter means. For escalation the answer was settled by the harm — the reminder simply does not go, and visibleForCategory suppresses the global-admin fallback whenever a contact is named, so the overdue escalation channel ends up empty. Assignment's harm is different: a deadline assigned to somebody who cannot open it. Whether that should be flagged only, warned at assign time, or refused outright is m's call, and it is a different question from the escalation one even though the shape is the same.

Verification pattern worth copying: cmd/server/http_smoke_escalation_visibility_test.go measures the consequence over HTTP under an enforcing role — the named user's own read of the matter returns zero, and returns rows once they are staffed — rather than asserting that a flag appeared.

Not closing; only m closes issues.

## The visibility half of this issue already has its core — reuse it, do not rebuild it m's escalation ruling of the same day is implemented on `mai/brunel/escalation-visibility-flag` ([`beb133b`](https://mgit.msbls.de/m/paliad/commit/beb133b), design at `docs/design-escalation-visibility-flag-2026-07-30.md`). It was built to serve this issue too, so the assignee work should add an enumerator and nothing else. **What is already there** — `internal/services/visibility_mismatch_service.go`: - `CanSeeProjects(ctx, userID, projectIDs) → map[uuid]bool` — the core. Answers "can this user see these matters" for any user, from any context. - `VisibilityMismatch` carries a **`relation`** label rather than being named after escalation. `RelationEscalationContact` exists; `RelationAssignee` is the one this issue adds. - The `/admin/escalation-visibility` page and its count badge render whatever relations the list contains, so an assignee row appears there with no new page. - No migration, nothing stored. The set is computed live, so no invalidation path is needed when a team changes. **What this issue supplies:** the pairs. Escalation reads them from `users.escalation_contact_id` × the owner's pending deadlines' projects; assignment reads them from `deadlines.assignee_id` / `appointments.assignee_id` × that row's project. **Three things that will bite if they are re-derived rather than reused** — each is a measured property, not a preference: 1. **Ask `paliad.can_see_project` AS the user, via `db.WithActingUser`.** Do not write the predicate in Go. `internal/services/visibility.go` already carries a mirror of it and that mirror **drifted from the function for two months** over mig 055's partner-unit branch. A check whose job is to report a disagreement must not be a third implementation that can disagree with both. 2. **Do not implement the check as "run the query as them and see whether rows come back."** That reports **zero** under today's permissive prod, because the bypassing role returns every row. It would go live at the S6 flip — dark for exactly the window the flag exists to cover. `TestVisibilityMismatch_SameUnderBothRoles` requires the identical answer under the bypassing role and under `paliad_app`, and that implementation fails it while passing everything else. 3. **`FROM unnest($1::uuid[])`, never `FROM paliad.projects`.** `projects` is itself gated by `can_see_project`, so an invisible project yields no row and "this user cannot see it" collapses into "it does not exist". **The one thing this issue must decide for itself:** what an assignee who cannot see the matter *means*. For escalation the answer was settled by the harm — the reminder simply does not go, and `visibleForCategory` suppresses the global-admin fallback whenever a contact is named, so the overdue escalation channel ends up empty. Assignment's harm is different: a deadline assigned to somebody who cannot open it. Whether that should be flagged only, warned at assign time, or refused outright is m's call, and it is a different question from the escalation one even though the shape is the same. Verification pattern worth copying: `cmd/server/http_smoke_escalation_visibility_test.go` measures the **consequence** over HTTP under an enforcing role — the named user's own read of the matter returns zero, and returns rows once they are staffed — rather than asserting that a flag appeared. Not closing; only m closes issues.
Author
Collaborator

Gebaut, auf mai/hades/issue-161-zustandigkeit

Migration 213assigned_to auf paliad.deadlines und paliad.appointments. Nullable, ON DELETE SET NULL, Teil-Index für den "meine Fristen"-Read. Prod steht auf 212, die Nummer ist frei.

Commits (branch, gepusht):

d9796f5 mig 213
3ff8c68 Service-Layer, beide Tabellen
66a5efd Views-Substrat + Dashboard
b8547c1 UI: Formulare, Widget, Filter-Achse
97375ce Reminder-Empfänger + Tests
051448c Entscheidungs-Dokument

Die Entscheidung, um die der Issue explizit gebeten hat

Zuständigkeit gibt keine Sichtbarkeit.

  • Schreiben: Wer die Akte nicht sehen kann, wird abgelehnt — HTTP 422, code: "assignee_not_visible".
  • Lesen: assignee_can_see meldet die Drift, die eine Ablehnung nicht verhindern kann.

Beide Hälften sind nötig. Zugriff hängt am Team. Eine spätere Team-Änderung nimmt ihn weg, ohne dass eine Frist geschrieben wird — in dem Moment läuft keine Prüfung, weil nichts gespeichert wurde. Nur ein Read sieht das.

Warum ablehnen statt nur markieren: Die Alternative erzeugt eine "meine Fristen"-Liste, deren Zeilen für die zuständige Person 404 liefern. Eine abgelehnte Eingabe ist ein sichtbarer Fehler mit genanntem Grund. Eine Liste, die man nicht öffnen kann, ist ein kaputtes Produkt, das dem Nutzer die Schuld gibt.

Gleiche Form wie deine Eskalations-Entscheidung vom selben Tag (findings-rls-s6 §9c): benachrichtigt werden und öffnen können sind zwei verschiedene Berechtigungen.

Wenn du es anders willst: ein Rückgabewert in validateAssignee (internal/services/assignee.go) schaltet die ganze Politik auf nur-markieren um. Sonst ändert sich nichts — die Read-Markierung ist schon da.

Drei Fälle, die die Regel nicht offensichtlich abdeckt

  1. Aktenwechsel strandet die zuständige Person. Erlaubt. Einen Aktenwechsel wegen einer Zuständigkeit zu blockieren, stellt die schwächere Tatsache über die stärkere. Es ist der Drift-Fall, die Markierung zeigt ihn.
  2. Persönlicher Termin (project_id IS NULL): nur der Ersteller kann zuständig sein. Alles andere wird abgelehnt.
  3. Nicht zugewiesen meldet NULL, nicht false. "Niemand ist zuständig" und "die zuständige Person hat keinen Zugriff" sind verschiedene Aussagen. Die UI warnt nur bei einer davon.

"Meine Fristen" heisst assigned_to = ich

Bewusst nicht "mir zugewiesen, oder von mir angelegt wenn niemand zugewiesen ist". Diese zweite Lesart lässt sich nicht in einem Satz erklären. "Erstellt von mir" gibt es schon als eigene Achse (personal_only, Chip "Nur persönliche"), und beide lassen sich kombinieren. Eine PA, die die Fristen eines Partners einträgt, braucht genau diese Trennung — sie zusammenzuwerfen wäre dieselbe Verwechslung wie created_by, nur an neuer Stelle.

Drei Modi: me, unassigned, user. unassigned ist der Triage-Blick, den ein Team braucht, um Fristen zu finden, die niemand übernommen hat.

Wo die Lesart jetzt existiert

Oberfläche Wie
Dashboard Widget "Meine Fristen", standardmässig sichtbar. Kein Zeitfenster — eine Frist, für die du zuständig bist, hört nicht auf deine zu sein, weil sie überfällig ist.
/deadlines, /appointments, /events ?assignee=me|unassigned|user (+ assignee_id)
/events/summary Gleicher Parameter, und er verändert die Zahlen — eine Karte, die Zeilen zählt, die die Liste darunter nicht zeigt, lügt.
Gespeicherte Ansichten, /inbox, /agenda FilterSpec.Scope.Assignee, auf beiden Schienen
Filterleiste Achse assignee, zwei Chips

UI

Zuständigkeits-Feld auf allen vier Formularen (Frist neu/Detail, Termin neu/Detail). Die Auswahl zeigt das Team der Akte — das ist die Bequemlichkeit, damit der Normalfall nie in die 422 läuft. Der Server bleibt die Instanz.

Der PATCH auf der Fristen-Detailseite hat Fehler bisher verschluckt. Er sagt jetzt, warum ein Speichern abgelehnt wurde.


Erweiterung über den Issue-Scope hinaus (leicht zurückzunehmen)

Reminder. assigned_to steht jetzt neben created_by in jedem Zweig der Digest-Empfängerregeln. Eine Zuständigkeit, die die Person nicht per Mail erreicht, ist Dekoration. Beide bleiben Empfänger.

Eigener Commit (97375ce), falls du das nicht willst. Es verbreitert das Empfänger-Prädikat genauso wie escalation_contact_id es schon tut (findings-rls-s6 §9c) — bitte zusammen mit dem Abschnitt lesen, nicht getrennt.


Nicht enthalten

  • Kalender-Feed-Scope — das ist #160, und es ist jetzt entsperrt: ScopeSpec.Assignee ist die Einschränkung, auf die es gewartet hat.
  • Kein Backfill. Jede bestehende Zeile ist unzugewiesen. Das ist der ehrliche Zustand — es gibt nirgends eine Quelle dafür, wer vor der Spalte zuständig war, und created_by abzuleiten würde genau die falsche Antwort erzeugen, die dieser Issue beheben soll.
  • Kein Sweep-Report für gedriftete Zuweisungen. Die Markierung ist überall sichtbar, wo eine Zeile gerendert wird, aber nichts sucht firmenweit danach. Naheliegender Folge-Issue.

Vorgefundener Defekt, hier nicht behoben

deadline_project_changed und appointment_project_changed werden von den Services geschrieben, fehlen aber in KnownProjectEventKinds — die Filter-UI kann sie nicht auswählen. Je eine Zeile. Gehört nicht zu einer Zuständigkeits-Änderung.


Geprüft

  • Harness auf Port 15489, Migration 213 angewendet. Live-Test TestDeadlineAssignee_Live grün: Ablehnung beim Schreiben, Drift beim Lesen, "meine" = zuständig statt eingetragen, Verlauf-Eintrag beim Wechsel.
  • scripts/ci-test-gate.sh grün (0 known-failing toleriert, keine neuen Fehler), make check-gofmt sauber, 411 Frontend-Tests grün.
  • Migrations-Gates grün: kein Duplikat, keine Kollision mit gepushten Nachbar-Branches, kein Transaktions-Statement im Body.
  • Branch ist auf origin/main gemergt.

Dokument: docs/design-assignee-visibility-2026-07-30.md

## Gebaut, auf `mai/hades/issue-161-zustandigkeit` Migration **213** — `assigned_to` auf `paliad.deadlines` und `paliad.appointments`. Nullable, `ON DELETE SET NULL`, Teil-Index für den "meine Fristen"-Read. Prod steht auf 212, die Nummer ist frei. **Commits** (branch, gepusht): | | | |---|---| | [d9796f5](https://mgit.msbls.de/m/paliad/commit/d9796f5) | mig 213 | | [3ff8c68](https://mgit.msbls.de/m/paliad/commit/3ff8c68) | Service-Layer, beide Tabellen | | [66a5efd](https://mgit.msbls.de/m/paliad/commit/66a5efd) | Views-Substrat + Dashboard | | [b8547c1](https://mgit.msbls.de/m/paliad/commit/b8547c1) | UI: Formulare, Widget, Filter-Achse | | [97375ce](https://mgit.msbls.de/m/paliad/commit/97375ce) | Reminder-Empfänger + Tests | | [051448c](https://mgit.msbls.de/m/paliad/commit/051448c) | Entscheidungs-Dokument | --- ## Die Entscheidung, um die der Issue explizit gebeten hat **Zuständigkeit gibt keine Sichtbarkeit.** - **Schreiben:** Wer die Akte nicht sehen kann, wird **abgelehnt** — HTTP 422, `code: "assignee_not_visible"`. - **Lesen:** `assignee_can_see` meldet die Drift, die eine Ablehnung nicht verhindern kann. Beide Hälften sind nötig. Zugriff hängt am Team. Eine spätere Team-Änderung nimmt ihn weg, **ohne dass eine Frist geschrieben wird** — in dem Moment läuft keine Prüfung, weil nichts gespeichert wurde. Nur ein Read sieht das. **Warum ablehnen statt nur markieren:** Die Alternative erzeugt eine "meine Fristen"-Liste, deren Zeilen für die zuständige Person 404 liefern. Eine abgelehnte Eingabe ist ein sichtbarer Fehler mit genanntem Grund. Eine Liste, die man nicht öffnen kann, ist ein kaputtes Produkt, das dem Nutzer die Schuld gibt. Gleiche Form wie deine Eskalations-Entscheidung vom selben Tag (findings-rls-s6 §9c): benachrichtigt werden und öffnen können sind zwei verschiedene Berechtigungen. **Wenn du es anders willst:** ein Rückgabewert in `validateAssignee` (`internal/services/assignee.go`) schaltet die ganze Politik auf nur-markieren um. Sonst ändert sich nichts — die Read-Markierung ist schon da. ### Drei Fälle, die die Regel nicht offensichtlich abdeckt 1. **Aktenwechsel strandet die zuständige Person.** Erlaubt. Einen Aktenwechsel wegen einer Zuständigkeit zu blockieren, stellt die schwächere Tatsache über die stärkere. Es ist der Drift-Fall, die Markierung zeigt ihn. 2. **Persönlicher Termin** (`project_id IS NULL`): nur der Ersteller kann zuständig sein. Alles andere wird abgelehnt. 3. **Nicht zugewiesen** meldet **NULL**, nicht `false`. "Niemand ist zuständig" und "die zuständige Person hat keinen Zugriff" sind verschiedene Aussagen. Die UI warnt nur bei einer davon. --- ## "Meine Fristen" heisst `assigned_to = ich` Bewusst **nicht** "mir zugewiesen, oder von mir angelegt wenn niemand zugewiesen ist". Diese zweite Lesart lässt sich nicht in einem Satz erklären. "Erstellt von mir" gibt es schon als eigene Achse (`personal_only`, Chip "Nur persönliche"), und beide lassen sich kombinieren. Eine PA, die die Fristen eines Partners einträgt, braucht genau diese Trennung — sie zusammenzuwerfen wäre dieselbe Verwechslung wie `created_by`, nur an neuer Stelle. Drei Modi: `me`, `unassigned`, `user`. `unassigned` ist der Triage-Blick, den ein Team braucht, um Fristen zu finden, die niemand übernommen hat. ### Wo die Lesart jetzt existiert | Oberfläche | Wie | |---|---| | Dashboard | Widget **"Meine Fristen"**, standardmässig sichtbar. **Kein Zeitfenster** — eine Frist, für die du zuständig bist, hört nicht auf deine zu sein, weil sie überfällig ist. | | `/deadlines`, `/appointments`, `/events` | `?assignee=me\|unassigned\|user` (+ `assignee_id`) | | `/events/summary` | Gleicher Parameter, und er **verändert die Zahlen** — eine Karte, die Zeilen zählt, die die Liste darunter nicht zeigt, lügt. | | Gespeicherte Ansichten, `/inbox`, `/agenda` | `FilterSpec.Scope.Assignee`, auf beiden Schienen | | Filterleiste | Achse `assignee`, zwei Chips | ### UI Zuständigkeits-Feld auf allen vier Formularen (Frist neu/Detail, Termin neu/Detail). Die Auswahl zeigt das Team der Akte — das ist die Bequemlichkeit, damit der Normalfall nie in die 422 läuft. Der Server bleibt die Instanz. Der PATCH auf der Fristen-Detailseite hat Fehler bisher **verschluckt**. Er sagt jetzt, warum ein Speichern abgelehnt wurde. --- ## Erweiterung über den Issue-Scope hinaus (leicht zurückzunehmen) **Reminder.** `assigned_to` steht jetzt neben `created_by` in jedem Zweig der Digest-Empfängerregeln. Eine Zuständigkeit, die die Person nicht per Mail erreicht, ist Dekoration. Beide bleiben Empfänger. Eigener Commit ([97375ce](https://mgit.msbls.de/m/paliad/commit/97375ce)), falls du das nicht willst. Es verbreitert das Empfänger-Prädikat genauso wie `escalation_contact_id` es schon tut (findings-rls-s6 §9c) — bitte zusammen mit dem Abschnitt lesen, nicht getrennt. --- ## Nicht enthalten - **Kalender-Feed-Scope** — das ist #160, und es ist jetzt entsperrt: `ScopeSpec.Assignee` ist die Einschränkung, auf die es gewartet hat. - **Kein Backfill.** Jede bestehende Zeile ist unzugewiesen. Das ist der ehrliche Zustand — es gibt nirgends eine Quelle dafür, wer vor der Spalte zuständig war, und `created_by` abzuleiten würde genau die falsche Antwort erzeugen, die dieser Issue beheben soll. - **Kein Sweep-Report** für gedriftete Zuweisungen. Die Markierung ist überall sichtbar, wo eine Zeile gerendert wird, aber nichts **sucht** firmenweit danach. Naheliegender Folge-Issue. ## Vorgefundener Defekt, hier nicht behoben `deadline_project_changed` und `appointment_project_changed` werden von den Services geschrieben, fehlen aber in `KnownProjectEventKinds` — die Filter-UI kann sie nicht auswählen. Je eine Zeile. Gehört nicht zu einer Zuständigkeits-Änderung. --- ## Geprüft - Harness auf Port 15489, Migration 213 angewendet. Live-Test `TestDeadlineAssignee_Live` grün: Ablehnung beim Schreiben, Drift beim Lesen, "meine" = zuständig statt eingetragen, Verlauf-Eintrag beim Wechsel. - `scripts/ci-test-gate.sh` grün (0 known-failing toleriert, keine neuen Fehler), `make check-gofmt` sauber, 411 Frontend-Tests grün. - Migrations-Gates grün: kein Duplikat, keine Kollision mit gepushten Nachbar-Branches, kein Transaktions-Statement im Body. - Branch ist auf `origin/main` gemergt. **Dokument:** `docs/design-assignee-visibility-2026-07-30.md`
mAi added the
done
label 2026-07-30 08:49:06 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/paliad#161
No description provided.