feat(event): join or leave one occurrence #209

Merged
nalum merged 1 commit from feat/occurrence-attendance into main 2026-08-26 18:33:47 +00:00
Owner

Closes #201.

Attending is open to everyone the matrix admits; editing belongs to an
event's owners (#205). That worked for a whole series and not for a single
date, because occurrences are derived rather than stored: recording
attendance on one Thursday means materialising it first, and splitting is
an owner's act — it detaches that date from the series permanently, for
everyone.

So a child who wanted one swimming lesson had to join all twelve, and
anyone who joined a series could not step out of a single date.

JoinOccurrence and LeaveOccurrence each resolve the slot, materialise
the occurrence if it is not already a child row, and edit only the
caller's own attendance. Neither carries a user parameter. The target
is always the requester, which makes "split someone else's series to add a
third person" unrepresentable rather than merely refused. Joining records
the attendee relation, so it confers presence and no authority.

Three behaviours chosen deliberately:

  • A no-op never splits. Tapping join on a date you already attend
    through the series materialises nothing and returns the parent
    unchanged. A permanent split is too high a price for an idempotent tap.
  • A cancelled occurrence refuses both, with FailedPrecondition. A date
    that is not happening takes no attendance, and a join that quietly
    succeeded would leave someone believing they are going to something the
    household called off.
  • A standalone API key is refused even with the admin role. There is no
    admin act available on an RPC that can only touch its caller.

The split path was factored rather than copied: resolveSlot is the
caller-agnostic half of the old owner-gated intake, and insertSplit is
now the one write seam every materialisation goes through, so uid minting,
the mutation trail, the audit entry and the live-update announce are
identical whoever splits.

Recorded as an amendment to ADR-0036, including the trade-off that a child
row whose last attendee leaves is left without an owner.

MCP and CLI deliberately carry no affordance yet: MCP has no attendance
verb at all and its usual principal is the standalone key these RPCs
refuse, and the CLI's per-occurrence version needs the scope dialog the
web reference has not built.

🤖 Generated with Claude Code

Closes #201. Attending is open to everyone the matrix admits; editing belongs to an event's owners (#205). That worked for a whole series and not for a single date, because occurrences are derived rather than stored: recording attendance on one Thursday means materialising it first, and splitting is an owner's act — it detaches that date from the series permanently, for everyone. So a child who wanted one swimming lesson had to join all twelve, and anyone who joined a series could not step out of a single date. `JoinOccurrence` and `LeaveOccurrence` each resolve the slot, materialise the occurrence if it is not already a child row, and edit only the caller's own attendance. **Neither carries a user parameter.** The target is always the requester, which makes "split someone else's series to add a third person" unrepresentable rather than merely refused. Joining records the attendee relation, so it confers presence and no authority. Three behaviours chosen deliberately: - **A no-op never splits.** Tapping join on a date you already attend through the series materialises nothing and returns the parent unchanged. A permanent split is too high a price for an idempotent tap. - **A cancelled occurrence refuses both**, with FailedPrecondition. A date that is not happening takes no attendance, and a join that quietly succeeded would leave someone believing they are going to something the household called off. - **A standalone API key is refused even with the admin role.** There is no admin act available on an RPC that can only touch its caller. The split path was factored rather than copied: `resolveSlot` is the caller-agnostic half of the old owner-gated intake, and `insertSplit` is now the one write seam every materialisation goes through, so uid minting, the mutation trail, the audit entry and the live-update announce are identical whoever splits. Recorded as an amendment to ADR-0036, including the trade-off that a child row whose last attendee leaves is left without an owner. MCP and CLI deliberately carry no affordance yet: MCP has no attendance verb at all and its usual principal is the standalone key these RPCs refuse, and the CLI's per-occurrence version needs the scope dialog the web reference has not built. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(event): join or leave one occurrence
All checks were successful
check / commits (pull_request) Successful in 7s
check / go (pull_request) Successful in 3m3s
check / web (pull_request) Successful in 4m17s
android / build (pull_request) Successful in 6m45s
check / report (pull_request) Successful in 3s
android / report (pull_request) Successful in 4s
ad175216ff
A repeating event's occurrences are derived rather than stored, so
attendance on a single date has nowhere to live until that date is
materialised — and materialising it is SplitOccurrence, which detaches
the date from the series permanently and for the whole household, and
is therefore an owner's act (ADR-0036). The two halves deadlocked: a
non-owner who wanted one swimming lesson had to join all twelve, and
somebody already on a series could not skip a single date.

JoinOccurrence and LeaveOccurrence resolve the slot exactly as Split
does, materialise it through the same seam, and edit one ref on the
resulting child row. Splitting stays an owner's act everywhere else
because it is destructive to everyone; it may be opened here for one
reason only — neither request carries a user uid, so the target is
always the caller and 'detach somebody's date, then put a third person
on it' is unrepresentable rather than merely refused. That is also why
joining writes an ATTENDEE ref: arriving by splitting must not leave
you governing the piece you broke off.

A no-op never splits. The edit runs against the projected child before
anything is written, so re-joining a date you already attend through
the series leaves it attached and returns the parent — a permanent
split is too high a price for an idempotent re-tap, which is exactly
what an offline queue does. A cancelled slot refuses both verbs: a
date that does not happen takes no attendance, and a join that quietly
succeeded would leave the caller believing otherwise.

Surface parity (rule 1): pkg/client carries both verbs, so CLI and MCP
can reach them, but neither ships an affordance here and that gap is
deliberate. MCP has no attendance verb at all — no AddUser tool — and
its usual principal is a standalone key, which is precisely what these
RPCs refuse. The CLI's event command group carries no occurrence verbs
either; the TUI calendar has split and cancel but no scope dialog, and
the web reference has not shaped that dialog yet (design handoff 0.1),
so building the CLI's copy first would invert the reference order.

Test report

Suite Tests Result Skipped
Unit 1434 ✅ pass 1
Integration 130 ✅ pass —

Coverage: 27.0%

Updated by the check workflow · commit 3935d3ba5a

<!-- ci-test-report --> ## Test report | Suite | Tests | Result | Skipped | | --- | --: | --- | --: | | Unit | 1434 | ✅ pass | 1 | | Integration | 130 | ✅ pass | — | **Coverage:** 27.0% <sub>Updated by the check workflow · commit 3935d3ba5a3d03c11dcfea9f21fa13b2ad58bebc</sub>

Android test report

Suite Tests Result Skipped
Unit (debug) 48 ✅ pass 0

Coverage: 2.9% of lines

Updated by the android workflow · commit 3935d3ba5a

<!-- android-test-report --> ## Android test report | Suite | Tests | Result | Skipped | | --- | --: | --- | --: | | Unit (debug) | 48 | ✅ pass | 0 | **Coverage:** 2.9% of lines <sub>Updated by the android workflow · commit 3935d3ba5a3d03c11dcfea9f21fa13b2ad58bebc</sub>
nalum force-pushed feat/occurrence-attendance from ad175216ff
All checks were successful
check / commits (pull_request) Successful in 7s
check / go (pull_request) Successful in 3m3s
check / web (pull_request) Successful in 4m17s
android / build (pull_request) Successful in 6m45s
check / report (pull_request) Successful in 3s
android / report (pull_request) Successful in 4s
to 3935d3ba5a
Some checks failed
android / build (pull_request) Successful in 7m17s
check / commits (pull_request) Successful in 18s
check / go (pull_request) Successful in 2m43s
check / web (pull_request) Successful in 4m18s
android / report (pull_request) Successful in 4s
check / report (pull_request) Successful in 3s
android / build (push) Has been cancelled
android / report (push) Has been cancelled
check / commits (push) Has been cancelled
check / go (push) Has been cancelled
check / report (push) Has been cancelled
check / web (push) Has been cancelled
tag / tag (push) Has been cancelled
2026-08-26 07:03:44 +00:00
Compare
nalum changed target branch from fix/date-only-lateness to main 2026-08-26 18:33:43 +00:00
nalum merged commit 3935d3ba5a into main 2026-08-26 18:33:47 +00:00
nalum deleted branch feat/occurrence-attendance 2026-08-26 18:33:48 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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!209
No description provided.