# CalDAV / Nextcloud calendar integration

Notes for [Phase 8](project-plan.md) and for the three services in this repo that
talk to the household calendar directly.

**This repo does not deploy Nextcloud.** It's listed in the project plan's hardware
table as an *already running* household service (`§ "Nextcloud instance | Calendar
backend (CalDAV sync target)"`), and `setup-container-host.sh` never touches it.
Everything below assumes you already have one and are pointing this stack at it.

Like `network-integration.md`, this is **guidance, not automation** — nothing under
this repo configures Nextcloud or Home Assistant for you.

## Who talks to the calendar, and in which direction

Four independent clients, and the direction of each matters more than the count:

| Client | Direction | What it does |
|---|---|---|
| `digest-engine/ingest/caldav.py` | **read-only** | Pulls events in the digest window for the household section of the quarter-daily digest |
| `chores/check.py` (`_household_currently_busy()`) | **read-only** | Asks "is there a currently-active event whose summary matches a quiet keyword?" before nudging anyone |
| `trash-calendar/sync.py` | **write** (create only) | Writes bin-collection dates from the municipal ICS feed onto the household calendar |
| Home Assistant's CalDAV integration | read/write | The general-purpose bridge — `calendar.*` entities, `calendar.create_event` |

These are **four separate connections to the same server**, not one shared client. That
is deliberate: `chores` runs as a oneshot timer job with no HTTP surface, `digest-engine`
runs on its own schedule, and neither should have to be up for the other to work.

## One app password, not four

All three of this repo's services deliberately read the **same** environment variable
names — `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, `CALDAV_VERIFY_TLS` — and
expect the **same single Nextcloud app password** pasted into each of their env files.

Create it at: **Nextcloud → Settings → Security → Devices & sessions → Create new app
password.**

**Not the account password.** An app password is revocable on its own, scoped to this
one integration, and keeps the account password out of files on the container host.
It's also *mandatory* once two-factor authentication is on: the DAV endpoints have no
way to prompt for a second factor, so a 2FA account simply cannot authenticate to
CalDAV with its normal password.

### What that choice actually costs

Sharing one credential is the right call at household scale — four credentials to
create, rotate and revoke, for four clients that are all equally trusted, is
bookkeeping without a security gain. But be clear about the two consequences:

1. **Rotating it means editing three files.** `digest-engine.env`, `chores.env`, and
   `trash-calendar.env` on the container host, plus re-entering it in Home Assistant's
   CalDAV integration. Nothing propagates it for you, and a service left with the old
   value fails *quietly* — every one of these clients is built to degrade rather than
   crash, so a stale credential looks like "the calendar had nothing to say."
2. **The read-only invariant is a code property, not a permission boundary.** A
   Nextcloud app password cannot be scoped to read-only, nor to a single calendar. The
   credential `digest-engine` holds is fully capable of deleting every event you own —
   what stops it is that `ingest/caldav.py` only ever issues reads, an invariant
   asserted in its own docstring and in the project plan's Phase 12 rule. If that
   module ever grew a write, no permission on the server side would catch it. Treat
   changes to those files accordingly.

## What each service needs beyond the shared four

| Variable | Used by | Meaning |
|---|---|---|
| `CALDAV_CALENDARS` | digest-engine | Which calendars to read (blank = all discovered) |
| `CALDAV_LOOKAHEAD_HOURS` | digest-engine | How far forward to look; the window is deliberately asymmetric (back over the digest window, forward over this) because a calendar is mostly useful forwards |
| `CALDAV_MAX_EVENTS` | digest-engine | Cap on events fed into the LLM context |
| `CALDAV_QUIET_KEYWORDS` | chores | Summary substrings that mean "don't nudge right now" — default `busy,meeting,call,movie,sleep` |
| `CALDAV_TARGET_CALENDAR` | trash-calendar | The **one** calendar it writes into, by display name |

`CALDAV_TARGET_CALENDAR` being singular is intentional: the read paths can happily
span several calendars, but a writer that had to *guess* which of several calendars a
bin-collection event belongs in would eventually guess wrong, in someone else's
calendar.

## The write path's ownership invariant

`trash-calendar` is the only thing here that writes, and it constrains itself to
events it created:

- Every event it creates gets a UID prefixed `smartesthome-trash-`, derived
  deterministically from the source feed's own content.
- It only ever *creates* under that prefix, and only ever checks for existence before
  creating.
- It never reads, modifies, or deletes anything else in the target calendar.
- A re-run with nothing new in the feed touches nothing at all.

So the blast radius of a bug there is "duplicate or missing bin-day events", not
"someone's appointments are gone". If you add another writer later, copy this shape.

## URL form

Point `CALDAV_URL` at Nextcloud's **DAV root**:

```
https://cloud.example.com/remote.php/dav
```

The `caldav` library discovers the principal and its calendars from there. Nextcloud
also documents the per-user form
(`https://<host>/remote.php/dav/principals/users/<username>/`); either works.

## Two traps worth knowing before you debug them

**Recurring events without expansion.** A weekly recurring event fetched without
`expand=True` comes back *once*, as its original master VEVENT carrying an RRULE — so
a naive client reports the meeting on the day it was first created, possibly years
ago, and nothing looks obviously broken. `digest-engine` passes `expand=True` and
retries for servers that reject it outright. Any new client needs the same.

**`CALDAV_VERIFY_TLS=false` is for a self-signed internal cert, and nothing else.**
It disables certificate verification entirely, which on a LAN-only Nextcloud behind
your own CA is a reasonable trade, and on anything reachable beyond the LAN is a
straightforward man-in-the-middle hole. If your Nextcloud has a real certificate —
and it should, Let's Encrypt is free — leave this `true`.

## Home Assistant's own integration

Separate from this repo's three clients, and set up in HA's UI rather than by anything
here: **Settings → Devices & Services → Add Integration → CalDAV**, same URL and app
password.

Phase 8's own guidance still stands: **create recurring events directly in Nextcloud,
not through HA**, and gate delete/move actions behind a confirmation step. HA's CalDAV
bridge is good at reading and at creating simple one-off events; recurrence rules are
where the impedance mismatch between the integration and the server shows up.

## Failure behaviour

Every client here fails soft, which is correct and also means an outage is quiet:

| Client | If Nextcloud is unreachable |
|---|---|
| digest-engine | Household section renders without calendar content |
| chores | `_household_currently_busy()` returns "not busy" — **fails open on purpose**, so a broken calendar check can never be the reason chores stop being nudged |
| trash-calendar | The sync run is skipped; tomorrow's timer tries again |

There is no alerting on any of this. A Nextcloud that's been down for a week looks
identical to a week with no calendar events — the same "silence isn't a signal" gap
noted in the project plan's open decision #38.

## Still unverified

1. **None of this has been run against a real Nextcloud instance** from this repo.
   `digest-engine/ingest/caldav.py` is written against the `caldav` library's
   documented API (sourced and dated in its own docstring); `chores`' busy-check and
   `trash-calendar`'s write path have never been executed against a live server at all.
2. **`CALDAV_TARGET_CALENDAR` matching is by display name**, which is whatever the
   calendar is called in Nextcloud's UI — not a URL or an ID. Renaming the calendar
   silently breaks the trash sync.
3. **Nobody has checked what happens when the app password is revoked** mid-operation
   — expected to surface as an auth error each client swallows into its normal
   degrade path, i.e. silently, but that's reasoning rather than observation.
4. **The quiet-keyword list is a guess at how this household actually labels events**
   (`busy,meeting,call,movie,sleep`). It matches on substrings of the summary, so an
   event called "Call with the bank" pauses every chore nudge in the house for its
   duration — which may or may not be what you want.
