Joining one occurrence of a repeating event needs a path a non-member can take #201

Closed
opened 2026-08-24 08:39:29 +00:00 by nalum · 2 comments
Owner

Follow-on from #188 / ADR-0036. Blocks the I'm going affordance the
calendar 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: AddUser now accepts a self-add from a
non-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:

SplitOccurrence(event_uid, occurrence_date, time_zone)   →  a real child row
AddUser(child_uid, me)                                   →  attendance on that row

SplitOccurrence still requires event membership — loadOccurrenceParent in
internal/services/event/occurrences.go:224 calls svc.RequireEventMember. So a
non-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 going with 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:

  1. The matrix row already says the right thing. EventService/SplitOccurrence
    admits 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.
  2. ADR-0031 draws the line: a role-only guard inside a handler becomes a
    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 going is 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, RemoveUser and CancelOccurrence on that occurrence all
open 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 an
answer: 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 going on a
repeating 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

  • §0.1's non-member sheet and its I'm going action (#193)
  • The scope dialog's non-member branch (#196)
  • The CLI's join key, which gates on membership today
    (cmd/eagraiclainne/tui/calendar.go:239) and is stale against #188 regardless

Update — option B, delivered in PR #209 (2026-08-25)

JoinOccurrence(event_uid, occurrence_date, time_zone) and its mirror LeaveOccurrence land on feat/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.html wires I'm going and Leave this one with 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.

Follow-on from #188 / ADR-0036. Blocks the `I'm going` affordance the calendar 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: `AddUser` now accepts a self-add from a non-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: ``` SplitOccurrence(event_uid, occurrence_date, time_zone) → a real child row AddUser(child_uid, me) → attendance on that row ``` `SplitOccurrence` still requires event membership — `loadOccurrenceParent` in `internal/services/event/occurrences.go:224` calls `svc.RequireEventMember`. So a non-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 going` with 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: 1. The matrix row already says the right thing. `EventService/SplitOccurrence` admits 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. 2. ADR-0031 draws the line: a **role-only** guard inside a handler becomes a 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 going` is 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`, `RemoveUser` and `CancelOccurrence` on that occurrence all open 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 an answer: 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 going` on a repeating 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 - §0.1's non-member sheet and its `I'm going` action (#193) - The scope dialog's non-member branch (#196) - The CLI's join key, which gates on membership today (`cmd/eagraiclainne/tui/calendar.go:239`) and is stale against #188 regardless --- ## Update — option B, delivered in PR #209 (2026-08-25) `JoinOccurrence(event_uid, occurrence_date, time_zone)` and its mirror `LeaveOccurrence` land on `feat/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.html` wires `I'm going` and `Leave this one` with 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.
Author
Owner

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/SplitOccurrence already
admits 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.IsMember ignores the relation type and AddMember adds
everyone as RELATION_TYPE_OWNER, so the whole-series self-add shipped in #188
already makes a joiner a full member — able to Update, Delete, RemoveUser
and CancelOccurrence on an event they merely joined. "Editing stays
member-only" is true and vacuous when membership is one self-add away.

One mechanism closes both: a new RELATION_TYPE_ATTENDEE value (adding an enum
value 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.

**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/SplitOccurrence` already admits 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.IsMember` ignores the relation type and `AddMember` adds everyone as `RELATION_TYPE_OWNER`, so the whole-series self-add shipped in #188 already makes a joiner a full member — able to `Update`, `Delete`, `RemoveUser` and `CancelOccurrence` on an event they merely joined. "Editing stays member-only" is true and vacuous when membership is one self-add away. One mechanism closes both: a new `RELATION_TYPE_ATTENDEE` value (adding an enum value 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.
Author
Owner

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 guards
ask IsEventOwner, so joining confers presence and no power. JoinOccurrence
inherits that — the caller lands on the materialised child as an attendee.

Decided: add LeaveOccurrence alongside it. An owner can already leave one
occurrence, 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 RequireEventLeaver was added to close. The pair is
symmetric: each splits the occurrence and edits only the caller's own
attendance, never anyone else's.

Both are new RPCs, so both need a PermissionMatrix row or the completeness
test 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.

**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 guards ask `IsEventOwner`, so joining confers presence and no power. `JoinOccurrence` inherits that — the caller lands on the materialised child as an attendee. **Decided: add `LeaveOccurrence` alongside it.** An owner can already leave one occurrence, 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 `RequireEventLeaver` was added to close. The pair is symmetric: each splits the occurrence and edits only the caller's own attendance, never anyone else's. Both are new RPCs, so both need a `PermissionMatrix` row or the completeness test 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.
nalum closed this issue 2026-08-26 18:33:48 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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
eagraiclainne/app#201
No description provided.