Per-user and per-project calendars readable in Outlook — unlock the scopes the feed already models, and solve the mirroring problem #160
Reference in New Issue
Block a user
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?
m, 2026-07-29: "Es geht um die CalDav Kalender, die man dann mit Outlook syncen kann." and then, after the Microsoft-365 constraint was put to him:
The constraint that shapes everything
HLC is on Microsoft 365, and Microsoft 365 does not offer CalDAV. Verified two ways: our own
docs/guide-outlook-calendar-subscription.mdstates it ("Microsoft 365 und Exchange Online bieten kein CalDAV an"), and bothhlc.comandhoganlovells.comcarryMS=ms…M365 domain-verification TXT records behind their Mimecast MX.So:
Read-only is therefore accepted, not overlooked. The ask is not two-way sync; it is more than one calendar, each subscribable.
Most of this is already built and was deliberately capped
This is v2 of an existing feature, not a new one. Measured against prod and the code:
paliad.calendar_feeds.scope_kindCHECKall_visible,personal_only,project,client,litigation,patent,case(mig 192)paliad.user_calendar_bindings.scope_kindCHECKscope_idconstraintinclude_personal,include_detailsscopeLabel()insettings-feeds.tspersonal_onlyandprojectscope_kind='all_visible'user_calendar_bindingsrowsuser_caldav_configrowsAnd the cap is explicit in
frontend/src/client/settings-feeds.ts:203:That was the right call for v1. m has now asked for the choice, so the picker stops being a lie.
The real challenge, in m's words: mirroring
One appointment can legitimately belong in several calendars at once — a project calendar and the individual's own. If a user subscribes to both, the same event must not appear twice in Outlook.
This is the part that is genuinely unbuilt and it is the reason this issue is not just "add a dropdown":
UIDmust be the same in every feed it appears in, so a client that holds two subscriptions can recognise it as one event. Get this wrong and every lawyer subscribed to two calendars sees doubles.include_personalinteracts with this. It already exists; its meaning under multiple scoped feeds needs restating.Scope
scope_kind/scope_id. Verify what it does today — it may already, since onlyall_visiblehas ever been requested.docs/guide-outlook-calendar-subscription.md— it currently describes one feed. Its Part 0 decision aid becomes more important, not less, once there are several.Out of scope
appointments.project_idis nullable and 0 rows use it — a real gap, but a different one. m was asked and did not choose it.Verified facts to carry in
CALDAV_ENCRYPTION_KEYsealscalendar_feeds.token_encryptedso a subscription link stays re-displayable. Unset ⇒ no new feed can be minted (501), though existing feeds still list and revoke.PALIAD_BASE_URLis baked into every minted subscription URL. Wrong value ⇒ every link minted in that environment is wrong, and Outlook has no edit-URL affordance — users must remove and re-add the calendar.Design committed — shift-1 (cronus)
Doc:
docs/design-calendar-scopes-mirroring-2026-07-29.mdCommit: https://mgit.msbls.de/m/paliad/commit/32a1a7723 (branch
mai/cronus/m-paliad-160-per-user)Design only, no code. Two of this issue's premises did not survive measurement, so they are corrected in the doc rather than built on.
Correction 1 — per-project feeds already ship
The matter page has „Diesen Kalender abonnieren" and it mints
scope_kind:"project"today:frontend/src/client/projects-detail.ts:3069(wireCalendarSubscribe), markupfrontend/src/projects-detail.tsx:682-692, wired at:2302. The v1 cap atsettings-feeds.ts:203is a cap on the Settings create-flow only, not on the product. The generator honours the scope as well —AppointmentService.ForScope(appointment_service.go:806) andDeadlineService.ForScope(deadline_service.go:1212) serveall_visible/personal_only/projectand refuse the four hierarchy scopes at mint with 400.Correction 2 — stable UIDs do not make two Outlook subscriptions merge
The issue reads the UID as the dedup mechanism across feeds. It is not. A UID makes a client update an event in place when it refreshes one calendar. Each internet-calendar subscription in Outlook is its own calendar folder; the overlay view draws both folders on one grid. Microsoft documents no cross-folder deduplication, and none was found. Apple and Google Calendar behave the same.
So doubling is not solved by UIDs at all. It is decided by what a user's set of feeds is allowed to contain.
Not verified and not verifiable from here: no Outlook client on mRiver, no M365 account — the same limit the user guide records in its §3.4. The design is built so the answer does not change it, only how loudly we warn.
The six decisions
caldav_ical.go:34-44). Pinned as a contract, because the natural way to implement per-scope feeds badly is to put the project id in the UID — and a shipped UID cannot be changed without every subscriber seeing every event duplicated once.UPDATE(deadline_service.go:592/697/749,projection_service.go:1873,scenario_builder_service.go:1148,rule_editor_orphans.go:227,dual_write.go:40); the onlyDELETEoutside tests is the explicit user delete. NoSEQUENCE— Paliad has no monotonic counter and a value that goes backwards is worse than an absent one.(scope_kind, scope_id, include_personal)alone, so it can be shown at mint time and in the Settings list. Subtractive feeds were rejected: revoking a narrow feed would silently widen the wide one.include_personalis a union flag on project-anchored feeds only; at most one of a user's feeds should carry personal appointments. Defaultfalseon project feeds, moved from the call site to the service. No UI — prod has 0 personal appointments.personal_onlystays out of the picker. It would mint a guaranteed-empty calendar (0 personal appointments;deadlines.project_idis NOT NULL, so a personal deadline cannot exist). Same class of lie the v1 comment refused, in the other direction.all_visible— it is computed per user from team memberships on every poll. What does not exist is „meine Fristen":paliad.deadlinesandpaliad.appointmentscarry no assignee, owner, responsible or attendee field, onlycreated_by, which records who typed the row in. A PA entering the partner's deadlines makescreated_bywrong in the common case. That is a schema gap, not a feed scope.What „unlock the picker" then means
Settings stops pretending every feed is
all_visible: it shows the real scope, names the matter (scope_idis already in the JSON payload —models.go:565— the TS type just drops it), and discloses overlap. The minting choice stays on the matter page, where the user has the matter in front of them.Slices
client/litigation/patent/case) — only if m wants themdocs/guide-outlook-calendar-subscription.mdOpen for m (via head)
Scale measured on prod 2026-07-29: 48 users, 19 projects, 11 deadlines, 2 appointments, 1 feed (
all_visible), 0 CalDAV bindings.Built — shift-2 (cronus)
Branch
mai/cronus/m-paliad-160-per-user, three commits plus a merge ofmain.c4387a70f1e2fc7b8990aQ2 — hierarchy scopes
client/litigation/patent/casenow cover the anchor matter and its whole subtree. One shared rule,scopeAnchorPredicate(internal/services/calendar_scope.go): exact match forproject, the materialised-path descendant test for the four. BothForScopeimplementations read it, so CalDAV and the ICS feed cannot drift.The visibility predicate stays on the event's own project rather than the anchor. Visibility inherits down the tree so the two agree today; only the per-event form survives that ceasing to be true, and it costs the same JOIN.
Mint asserts the anchor's level: a
client-kind feed on a case project is a 400. The scope is immutable once minted andscope_kindis what names the feed in the UI, so a mismatch would be permanent and the only remedy is remove-and-re-add in Outlook.Two duplicate scope validators (
validateScopein binding land,validateCalendarScopein feed land — same rule, different wording) are now oneValidateCalendarScope. The two tables share the CHECK it mirrors.Q1 — both
Minting stays on the matter page. The subtree choice appears on client / litigation / patent, not on a case: a case is the bottom of the hierarchy, so both options would return the same calendar. It is not gated on "has children today" — the scope is immutable, so a litigation with no patents yet is exactly where "and everything below" is the right pick.
Settings is now the inventory.
ListForUserLEFT JOINs the anchor matter for its title. The visibility predicate sits in the JOIN, not the WHERE: a feed whose matter the owner has since lost must still be listed — it is their link and they have to be able to revoke it — but it must stop naming a matter they can no longer see. In the WHERE it would hide the row and strand a live subscription.Q3 — overlap
internal/services/calendar_overlap.go, a pure function of the scopes:all_visiblemeets everything;personal_onlymeets a matter feed only throughinclude_personal; two anchored feeds meet when one's covered set contains the other's anchor. Shown in the Settings list and alerted at mint time, because minting is when the user is deciding where to paste the link. Never prevented.The mirroring contract, pinned
internal/services/caldav_ical_uid_contract_test.go. The UID is a function of the row id and type and of nothing else — asserted across everyICSOptionsshape the formatter accepts, across an edit changing title, date,updated_atand description at once, and against the literal spelling. Negative controls: the two documents in the edit case must differ (else the equality proves nothing), two deadlines must not share a UID, a deadline and an appointment carrying the same row uuid must not collide, and the feed must name an appointment identically to the CalDAV push.Tests
The old hierarchy test asserted only that the four kinds were refused, so their SQL had never executed. Replaced with row counts over a seeded Client→Litigation→Patent→Case chain (4 / 3 / 2 / 1), a narrowness control against
projectscope on the same anchor, and a visibility control proving a stranger sees zero through a client-scoped feed. The overlap rule is asserted in both directions per case — it is documented as symmetric, and a one-directional prefix test is the obvious way to break that silently — plus a separator-aware control provingc.ldoes not swallowc.long.Gate
Full
scripts/ci-test-gate.shagainst a private harness:go vetclean, whole suite with-race, 0 known-failing tolerated, no new failures.check-gofmt.shexit 0. 420 frontend tests. Branch merged withmain(which had moved) and re-run after the merge.Not built, deliberately
The assignee column from m's Q4 second clause. Its own issue, per the head's instruction. No feed is scoped to "my deadlines" here.
One thing nobody here can check
That two overlapping subscriptions really do show doubles in a live Outlook. No Outlook client on mRiver and no M365 account — the same limit the user guide records in its §3.4, where this is now written down as point 6. Microsoft documents no cross-folder merge and none was found; the absence is the evidence. The design does not depend on the answer — it only changes how loudly we warn.