Joining one occurrence of a repeating event needs a path a non-member can take #201
Labels
No labels
adr
android
area/calendar
area/design-system
area/i18n
area/jobs
area/offline
area/server
area/testing
bug
ci
duplicate
enhancement
help wanted
invalid
notifications
question
reliability
security
severity/low
severity/medium
tracking
web
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
eagraiclainne/app#201
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-on from #188 / ADR-0036. Blocks the
I'm goingaffordance thecalendar handoff specifies in §0.1 and the scope dialog in #196.
What does not work today
#187 decided that joining an event is open to everyone but a Guest, and #188
delivered that for a whole series:
AddUsernow accepts a self-add from anon-member.
Joining one occurrence of a repeating event is a different path. There is no
per-occurrence attendance field — the client materialises the occurrence first
and edits the child row:
SplitOccurrencestill requires event membership —loadOccurrenceParentininternal/services/event/occurrences.go:224callssvc.RequireEventMember. So anon-member cannot take the first step, and joining a repeating event necessarily
takes the whole series.
That leaves §0.1's non-member sheet offering
I'm goingwith no way to answer"just this Thursday", and it leaves #196's scope dialog with only one branch for
a non-member.
Does it need a matrix row?
No. Two reasons:
EventService/SplitOccurrenceadmits Admin, Member and Child and excludes Guest — exactly "open to all
except guest". What stops a non-member is the resource guard inside the
handler, not the role gate.
sub-verb row (
Create.standalone) so clients can gate the affordance on it.A guard that needs the resource — ownership, membership, or "is the target
user the caller?" — stays in the handler. Self-add-driven splitting is the
second kind: it depends on the event and on who is being added.
Clients need no new matrix key either. A client already holds the event, so
"am I on this?" is a local question, the same way #188's
I'm goingis gated.The part that needs a decision, not just a guard
Splitting is not a private act. It materialises a child row that everyone
sees, permanently detaches that date from the series (it never rejoins), and a
later rule change deletes the child — taking the attendance decision with it.
A non-member joining one date therefore changes the shape of someone else's
series.
Worse, membership is the edit boundary. Once a non-member self-adds to the
materialised child they are a member of that child row, which means
Update,Delete,RemoveUserandCancelOccurrenceon that occurrence allopen to them. Joining one Thursday would quietly grant the power to move or
cancel that Thursday for everyone on it. That is an escalation the whole-series
join does not have, and it is the real question here — not the matrix.
Options
A. Relax the split guard to allow a split that accompanies a self-add.
Cheapest. Needs the two calls to be one intent, which the client cannot promise —
nothing stops a caller splitting and then not joining, so in practice this is
"any non-Guest may split any occurrence". Leaves the escalation above wide open.
B. One RPC:
JoinOccurrence(event_uid, occurrence_date, time_zone).The server splits and adds the caller in a single transaction, so a non-member
can never split for any other purpose. The guard is "the user being added is the
caller", the same shape as
RequireEventJoiner. Escalation still needs ananswer: either the child's member list stops conferring edit rights for someone
who was not on the parent, or the join is recorded in a way that does not make
them a member.
C. Leave it: joining a repeating event takes the whole series.
Zero work, and honest — the handoff already records it.
I'm goingon arepeating event says so, and the scope dialog keeps one branch for non-members.
The cost is that a child who wants one swimming lesson joins twelve.
Recommendation: B, with the escalation answered explicitly, or C if the
household would rather not carry a new RPC for it. Not A.
Whichever is chosen amends ADR-0036, which currently records that editing stays
member-only.
Affected
I'm goingaction (#193)(
cmd/eagraiclainne/tui/calendar.go:239) and is stale against #188 regardlessUpdate — option B, delivered in PR #209 (2026-08-25)
JoinOccurrence(event_uid, occurrence_date, time_zone)and its mirrorLeaveOccurrenceland onfeat/occurrence-attendance: the server resolves the slot exactly as Split does, materialises through the same seam, and edits one ref on the child row in a single transaction. Neither request carries a user uid, so the target is always the caller — "detach somebody's date, then put a third person on it" is unrepresentable rather than merely refused. The escalation this issue named is answered the way option B asked: joining writes an ATTENDEE ref, so arriving by splitting never grants authority over the piece broken off. Matrix rows admit Admin, Member and Child — exactly not-Guest — and a no-op never splits.The updated
calendar-demo.dc.htmlwiresI'm goingandLeave this onewith the scope dialog on a repeating event; the client work (#193/#196) should call these RPCs, not Split+AddUser.Closes when the PR merges. Still open elsewhere: the CLI's join key gates on membership and is stale against #188 regardless, and whether an attendee may edit after joining is #257.
Decided: option B — one
JoinOccurrence(event_uid, occurrence_date, time_zone)RPC that splits and self-adds in a single transaction, guarded on "the user
being added is the caller". No matrix row: the guard needs the resource, so
ADR-0031 keeps it in the handler, and
EventService/SplitOccurrencealreadyadmits Admin, Member and Child while excluding Guest.
The escalation still has to be answered, and it turns out to be bigger than
this issue:
domain.IsMemberignores the relation type andAddMemberaddseveryone as
RELATION_TYPE_OWNER, so the whole-series self-add shipped in #188already makes a joiner a full member — able to
Update,Delete,RemoveUserand
CancelOccurrenceon an event they merely joined. "Editing staysmember-only" is true and vacuous when membership is one self-add away.
One mechanism closes both: a new
RELATION_TYPE_ATTENDEEvalue (adding an enumvalue is allowed by ADR-0035; renaming or renumbering is not), recorded when
someone adds themselves. Presence and reads follow the list as they do now;
the edit guards ask for an organiser relation instead. Every stored row today
is
OWNER, so nobody currently on an event changes.This RPC lands on top of that split, not before it.
Unblocked, and the scope is now two RPCs.
The escalation this issue could not answer is answered: PR #205 split attendance
from authority. A self-add records
RELATION_TYPE_ATTENDEE, and the edit guardsask
IsEventOwner, so joining confers presence and no power.JoinOccurrenceinherits that — the caller lands on the materialised child as an attendee.
Decided: add
LeaveOccurrencealongside it. An owner can already leave oneoccurrence, because they may split. Someone who joined the series cannot, so
without the mirror they are trapped on every date of a series they joined —
exactly the trap
RequireEventLeaverwas added to close. The pair issymmetric: each splits the occurrence and edits only the caller's own
attendance, never anyone else's.
Both are new RPCs, so both need a
PermissionMatrixrow or the completenesstest fails the build: Admin, Member and Child, and not Guest — the same shape as
AddUser, which is what "open to all except guest" already means.