App reference

ADR-0036: The calendar is household-visible, editing stays with the event's owners

Metadata

  • Status: Accepted
  • Date: 2026-08-24
  • Deciders: EagraΓ­ Clainne Team
  • Related: ADR-0002 (centralised RPC authorization), ADR-0009 (service-layer authorization with resource ownership), ADR-0016 (api keys and standalone principals), ADR-0031 (sub-verb matrix entries)
  • Amends: ADR-0009 decision point 2 β€” event Read and the self-add half of AddUser no longer require membership, and every other event verb now asks for an OWNER relation rather than bare presence on the guest list.
  • Constrained by: ADR-0035 (stored-data evolution) β€” the new relation is an added enum value; nothing rewrites a stored row.
  • Issues: #187 (the decision), #188 (this change), #201 (the occurrence amendment below), #257 (the last-member amendment below)

Context

Two doors onto the same events disagreed about who may open them.

EventService.ListOccurrences applies no membership filter. It expands the window and returns every event in it β€” names, locations, attendees β€” to any caller the role matrix admits, which for that RPC includes GUEST. That is the shipped behaviour and the calendar grid is built on it. Calendar.tsx draws the whole household and puts a who-filter on top.

Every other door refused. Read, Update, Delete, AddUser, RemoveUser, SplitOccurrence and CancelOccurrence all called svc.RequireEventMember, and ListEvents filtered to the caller's own events. So the grid showed a member their partner's dentist appointment and then returned PermissionDenied when they tapped it. Worse, AddUser required membership too, which made joining impossible: nobody but an admin could put themselves on the school run they could plainly see.

Opening the join door then exposed a second disagreement, this time inside the model rather than between two RPCs. Event.users carried one fact and was asked two questions. Every ref was minted RELATION_TYPE_OWNER by the same constructor, domain.IsMember tested presence and ignored the relation, and every edit guard β€” Update, Delete, RemoveUser, SplitOccurrence, CancelOccurrence β€” went through that presence test. So long as joining required membership the conflation was harmless: you could only be on an event somebody with authority put you on. The moment anyone could put themselves on any event, "editing stays member-only" became true and vacuous β€” membership was one call away, and a member or child could add themselves to a partner's appointment and delete it a call later.

The calendar design handoff (docs/design_handoff_calendar/README.md Β§0) raised this as blocking and put two coherent answers side by side. Either relax Read to household-wide visibility and keep editing member-only, or filter ListOccurrences down to the caller's own events and make everything visible also editable. The second answer removes a shipped feature and turns one family calendar into four private ones.

Decision

Everyone in the household reads the whole calendar. Everyone except a guest may join any event on it. Changing an event remains the business of the people who own it β€” and joining, from now on, is not how you come to own one.

Attendance and authority stop being the same fact. Event.users keeps answering "who is going" by presence, and answers "who may change this" by relation.

  1. Read drops its membership guard. The EventService/Read matrix row already admits ADMIN, MEMBER, CHILD and GUEST, and that role gate is now the whole check. Opening an event agrees with seeing it, which is what ListOccurrences has always allowed.

  2. AddUser allows a caller to add themselves β€” as an attendee. The guard becomes svc.RequireEventJoiner, which passes when the target uid is the requester's own and otherwise falls through to svc.RequireEventOwner. A self-add writes the new RELATION_TYPE_ATTENDEE ref: it says you are going and nothing else. Being put on the list by someone who owns the event is the older act and keeps writing RELATION_TYPE_OWNER.

    RELATION_TYPE_ATTENDEE = 13 is an added enum value, stored on events only, never derived and never on a User row (ADR-0004's stored/derived split, ADR-0035's add-never-rename rule).

  3. The permission matrix is untouched. AddUser admits ADMIN, MEMBER and CHILD and not GUEST, so "open to all except guest" needs no new row and no sub-verb entry (ADR-0031). The role gate already draws that line, and the resource guard is the only thing that moved.

  4. The edit guards ask for the owner relation, not for presence. Update, Delete, SplitOccurrence and CancelOccurrence take svc.RequireEventOwner inside their locked reads (ADR-0009 point 6), as does AddUser when the target is somebody else. svc.RequireEventMember is gone rather than left lying about: a presence-based guard in an authz package is a trap now that presence is self-service.

  5. Leaving mirrors joining. RemoveUser takes svc.RequireEventLeaver, the exact mirror of the joiner guard: taking yourself off an event is not an edit of somebody else's event, and an attendee who cannot leave is worse off than one who never joined. Taking anyone else off needs ownership.

  6. A guest-list rewrite preserves relations. Update's user_uids replaced the whole list with owner refs. It now keeps each surviving person's existing ref and mints an owner ref only for someone new. A client re-sending the list to save an unrelated edit must not quietly promote every attendee who joined.

  7. Reads and presence are untouched. domain.IsMember, ListEvents' filter, "who's going", the calendar row's marks, the notification recipient lists and the per-user export scope all keep asking presence. An attendee is on the event. ListEvents keeps answering "what am I on?", which is a different question from "what is happening in this house?", and the grid asks the second one through ListOccurrences.

  8. A standalone api key neither joins nor leaves. Its effective uid is the key's own (ADR-0016), so a self-add would put a machine principal on a guest list. Both mirror guards send it down the owner path instead.

What a stored row means now. Every UserRef ever written to an event carries RELATION_TYPE_OWNER: domain.OwnerRef is the only constructor the create path, the update path and the member half of AddUser have ever used, and occurrence children are proto.Clones of their parent. So every row in every household reads today exactly as it read yesterday β€” the owner guard admits precisely who the membership guard admitted. Attendee refs can only come into existence through a self-add made by this version or later. Nothing is rewritten, no backfill is needed and none would have been possible (ADR-0035).

Amendment: attendance on one occurrence (issue #201)

The decision above is whole-series. It does not reach a single date of a repeating event, because an occurrence is derived rather than stored (ADR-0032): before anyone's attendance on one Tuesday can be recorded, that Tuesday has to become a row β€” and minting it is SplitOccurrence, which permanently detaches the date from the series for the whole household and is therefore an owner's act under decision point 4. So the two halves deadlocked: a non-owner who wanted one swimming lesson had to join all twelve, and somebody on a series could not skip a single date.

EventService.JoinOccurrence and EventService.LeaveOccurrence close it. Each takes an event uid, an occurrence date and the zone that slot key was produced in; each resolves the slot exactly as SplitOccurrence does, materialises it through the same seam when it is not already a child row, and edits one ref on that row.

  1. The target is always the caller β€” there is no user uid. That absence is the whole guard, and the reason splitting could be opened here while staying closed in SplitOccurrence. A Join(event, date) cannot express "and put my brother on it", so the escalation the decision was written to prevent β€” detach somebody's date, then govern or populate it β€” is unrepresentable rather than merely refused. svc.RequireOwnAttendance is thus isSelf against the requester and nothing else; a standalone api key, which is nobody (decision point 8, ADR-0016), has no attendance to change, and an ADMIN key gets no bypass because there is no admin act available to it here. Joining writes an ATTENDEE ref, so splitting this way never makes the splitter an owner of what they split.

  2. A no-op never splits. The edit is applied to the projected child before anything is written: when it changes nothing β€” the caller already goes that day through the series, or was never going β€” the slot keeps its place in the series and the parent event is returned unchanged, recurrence_of empty. A permanent split is too high a price for an idempotent re-tap, and re-tapping is exactly what an offline queue does. When the edit does change something, the split and the change land as one insert, so the child never exists in a state nobody asked for. The matrix rows are AddUser's: ADMIN, MEMBER, CHILD, not GUEST.

  3. A cancelled occurrence takes no attendance. A tombstoned date does not happen, so both verbs refuse it with FailedPrecondition rather than record who is going to it or who has withdrawn from it. Refusing symmetrically is the deliberate choice: the alternative β€” allow leaving, refuse joining β€” buys nobody anything, because leaving a date that is already off changes nothing anyone can observe, while a join that quietly succeeded would leave the caller believing they are going to something the household called off.

The trade-off this adds to the list below: leaving the last person off a materialised occurrence leaves an ownerless child row, editable only by an admin. Superseded by the #257 amendment below β€” that state can no longer be reached by leaving: the last person's leave is refused, on the parent and on a materialised child alike, and the way off a row you are alone on is deleting it. The rest of the paragraph stands: a split row is created by the act rather than merely abandoned by it, it is kept regardless, and deleting it on the way out would silently re-attach the date to the series.

An event a member is not on therefore reads in full and edits not at all. The clients render that as a read-only sheet whose single action is joining it β€” title, when, where, who is going and the note, no per-field chevrons, no overflow menu. The design handoff Β§0.1 owns the shape.

Amendment: an event keeps at least one person (issue #257)

Ruled 2026-08-25 while reconciling the calendar design handoff with this ADR. The handoff had proposed making an event with no attendees household-owned β€” any member may edit it β€” so that a Birthday is never uneditable and Save nobody going never locks an event. The household ruled the other way: an ownerless event stays admin-only, and the state is prevented from arising instead. The last person on an event cannot leave it; leaving it means deleting it.

  1. The guard is the domain's, not a handler's. domain.RemoveMember returns ErrLastMember (FailedPrecondition, code LAST_MEMBER) when the removal would empty the guest list, and its message names delete as the way out. RemoveUser and LeaveOccurrence inherit it through the one seam they share, and Update's guest-list rewrite applies the same verdict inline. Create is untouched: a Birthday starts with nobody on it by design β€” the rule is that a list never returns to empty, not that it never starts there.

  2. A refused leave costs no split. On a still-derived slot the guard runs against the projected child before anything is written (decision point 10), so refusing the last person's LeaveOccurrence leaves the slot in its series.

  3. The accepted consequence: someone who joins an occurrence of an attendee-less household date is its only person and cannot leave it again β€” an accidental join sticks until an owner or admin deletes the child row, which re-derives the slot into its series and is exactly the undo the mistake calls for. The alternative β€” exempting sole attendees from the guard β€” reopens the orphaning path for precisely the rows the guard exists to protect.

  4. No authorization change. The guard is a domain verdict like ErrLastAdmin, not a permission: an admin hits it the same as anyone. The matrix is untouched.

Consequences

Positive

  • The grid and the sheet tell the same story. Tapping an event no longer fails on data the same session just displayed.
  • Joining works. A member or child adds themselves to the school run without finding an admin, which is the act a shared calendar exists for.
  • Joining is safe to leave open. Because the join door writes attendance and not authority, opening it wide costs the household nothing: the worst a caller can do to an event they have no part in is put their own name on it and take it off again.
  • The change is small and reversible. Two guards moved, one helper appeared, no data changed shape and no matrix row changed.

Negative / trade-offs

  • A guest reads every event in the house, attendees and locations included. That was already true of the grid, and it is now true of the detail. A household that wants a genuinely private event has nowhere to put it.
  • The read side of events and the read side of items now differ. Items stay owner-or-admin (ADR-0009 point 3, ADR-0014). Anyone reasoning about visibility has to hold two rules rather than one, so both rules are stated in the handler comments.
  • Non-member sheets need a second shape on every client. Absent that shape a non-member sees controls the server will refuse, which the frontend plan bans. The shape is now needed for attendees too, not only for people off the list: joining no longer turns the sheet into Β§5.1's editor.
  • An event's guest list is no longer flat. "Who's going" reads the same, but two people on it can now have different powers, and no surface renders that difference yet β€” the web and Android sheets gate editing on the role matrix alone (can("EventService", "Update")) and will over-show to an attendee exactly as they already over-show to a non-member. The CLI's canManageEvent mirror is updated here; the two card surfaces are the outstanding parity work.
  • ADR-0009's "any event member can modify or delete the event" trade-off is retired, and the tier it said would need a data-model change is the data model change: one enum value.

Alternatives considered

  • Filter ListOccurrences to the caller's events. Coherent and cheaper to reason about, because everything visible would also be editable. It deletes shipped behaviour, invalidates the who-filter, the dot rule and the week view default, and leaves a family with four private calendars in one app. Rejected by the household in issue #187.
  • A Read.other sub-verb matrix row (ADR-0031). It would let the household choose between the two answers in configuration. Nobody asked for the choice, and a matrix row that no client reads is a setting that drifts. Rejected as speculative.
  • A per-event private flag. Real privacy for the one appointment that needs it, but it adds a field, a guard branch and a client affordance for a case the household has not met. Left for whenever it does.
  • Leave presence as the authority test. The smallest possible change: ship the open join door and accept that joining confers editing. Rejected as the privilege escalation it is β€” an authorization boundary a caller can cross by calling one unprivileged RPC is not a boundary.
  • A stored creator field on the event. The tier ADR-0009 said was missing, and it would name a single responsible person. It answers a different question ("who made this") than the one the guards ask ("who answers for this"), it makes co-owned family events single-owner, and it needs a backfill for every stored row β€” which ADR-0035 forbids until a migration mechanism exists. The relation type needs none.
  • A sub-verb matrix row (ADR-0031) for the attendee tier. Attendee-ness is a property of the caller's relation to one resource, not of their role, so it cannot be expressed as a matrix row. Rejected as a category error.
  • Relax Update too, so visible means editable. It matches the "no disabled controls" instinct and needs no read-only sheet. It also lets any member or child move an appointment they have no part in. Rejected: reading a shared calendar and rewriting it are different acts.