Story for milestone M1. Read docs/STORY-WORKFLOW.md first — it defines the process, gates, and patterns; this issue defines only the specific work.
crates/nuncio-cal is a fully real, tested engine crate (RFC 5545 iCalendar parsing, a real CalDavClient REPORT transport, a CalendarBackend trait, MockCalendarBackend) — but it is completely disconnected from the running daemon. nunciod/Cargo.toml:15 declares nuncio-cal as a dependency, yet grep -r nuncio_cal crates/nunciod/src returns zero matches: it is a genuinely orphaned crate. nuncio-store::db::DatabaseEngine already has working, tested calendar CRUD (save_calendar_event/list_calendar_events/get_calendar_event, crates/nuncio-store/src/db.rs:918-1035) that nothing ever calls in production. Meanwhile the CLI's nuncio cal list/nuncio cal sync (crates/nuncio-cli/src/runner.rs:345-361) fabricates its output: list always prints "Calendar Events: 0 events found" and sync only flips a local EventBus status flag via CoreCommand::SyncAll — no calendar backend is ever touched. This story wires all of that together into a real, authenticated gRPC vertical, the same way the Mail service already does for email.
Goal
Add a nuncio.v1.Calendar gRPC service with an honest surface — Sync, ListEvents, GetEvent — backed by the daemon's real store and (for Sync, when injected) a real CalendarBackend. ListEvents/GetEvent MUST always read genuine persisted data (never a fabricated event). Sync MUST NOT fake a CalDAV write: there is currently no way to persist a per-account CalDAV collection URL/credential (nuncio_core::AccountConfig has no CalDAV fields), so the production (non-test) Sync path has nothing real to connect to yet — it must return an honest error (e.g. "no CalDAV configuration exists for this account") rather than pretending to sync. Only the test-injection seam (mirroring MailEngineOverrides) exercises a working, mocked Sync. No create/delete RPCs — this story does not add real CalDAV write support. The CLI's cal list/cal sync must become real thin gRPC clients, replacing the fabricated strings in runner.rs:345-361.
Files to touch / patterns to mirror
Mirror the Mail vertical throughout — it is the fully-built reference for this exact shape:
crates/nuncio-proto/proto/nuncio/v1/nuncio.proto — add service Calendar (mirror the service Mail doc comment and SECURITY note at lines 223-230) plus its messages. New messages:
message CalendarEvent mirroring nuncio_core::model::CalendarEvent (crates/nuncio-core/src/model.rs:104-122) field-for-field: id, account_id, calendar_id, summary, start_time (int64), end_time (int64), rrule (optional string), location (optional string). (Named CalendarEvent, not Event — Event is already taken by System's push-event wrapper at proto line 48.)
message CalendarSyncRequest { string account_id = 1; string calendar_id = 2; int64 start_window = 3; int64 end_window = 4; } — carries calendar_id+window because CalendarBackend::fetch_events (see below) is inherently scoped that way, unlike Mail's account-wide Sync.
After editing, regenerate the golden per §5 of STORY-WORKFLOW.md (recipe is also printed by the failing test in crates/nuncio-proto/src/lib.rs:97-116).
crates/nuncio-proto/src/client.rs — add AuthenticatedCalendarClient type alias + connect_calendar(addr, token), mirroring AuthenticatedMailClient/connect_mail exactly (lines 96-98, 167-182). Add matching unit tests mirroring connect_mail_fails_closed_on_invalid_token/connect_mail_reports_transport_error_when_daemon_unreachable (lines 317-332).
crates/nunciod/Cargo.toml — no change needed; nuncio-cal is already a dependency (line 15), just finally used.
crates/nunciod/src/lib.rs — add pub mod calendar_sync; (alongside the existing pub mod sync; at line 8).
crates/nunciod/src/calendar_sync.rs (new file) — mirror crates/nunciod/src/sync.rs's shape (its whole doc comment explains the pattern). Add:
pub async fn sync_with_backend(db: &DatabaseEngine, backend: &dyn CalendarBackend, calendar_id: &str, start_window: i64, end_window: i64) -> Result<usize, CalendarSyncError> — calls backend.fetch_events(calendar_id, start_window, end_window), then db.save_calendar_event(&event) for each result, returning the count. Pure fetch-and-persist, unit-testable directly with MockCalendarBackend, exactly like sync.rs's fetch_and_persist.
There is deliberately norun_account_sync/run_all_accounts_sync production entry point yet (mirroring sync.rs's real-backend builders) — there is no persisted per-account CalDAV config to build a real CalDavClient from. Document this gap in a doc comment; a later story adds account-level CalDAV config and the real production entry point.
crates/nunciod/src/grpc.rs — mirror the Mail wiring throughout:
CalendarGrpcService { db: Arc<DatabaseEngine>, overrides: CalendarEngineOverrides } implementing a new nuncio_proto::v1::calendar_server::Calendar trait:
list_events/get_event: always real — call db.list_calendar_events(&req.account_id, req.start_window, req.end_window) / db.get_calendar_event(&req.event_id) directly (mirror MailGrpcService::list_messages/get_message, lines 496-538), map nuncio_core::model::CalendarEvent <-> proto CalendarEvent (new map_calendar_event_to_proto helper, mirroring map_email_to_proto).
sync: if overrides.calendar_backend is Some, call calendar_sync::sync_with_backend; if None, return Status::internal("no CalDAV configuration exists for account '{account_id}'; per-account CalDAV configuration is not yet implemented") — never a fabricated success (mirror the doc-comment style of MailGrpcService::sync, lines 646-677, but note production always errors today).
Add Calendar to serve_on_listener_with_overrides's signature (new calendar_overrides: CalendarEngineOverrides param) and mount it behind its OWN BearerAuthInterceptor, exactly like every other service (lines 1199-1219 for the Mail/Filters pattern) — update the function's doc comment's invariant list and serve/serve_on_listener's call sites to pass CalendarEngineOverrides::default().
Add a calendar_rpcs_reject_missing_bearer_token test in grpc.rs's own #[cfg(test)] mod tests, mirroring mail_rpcs_reject_missing_bearer_token (lines 1942-1974).
crates/nuncio-cli/src/args.rs — CalSubcommand (lines 337-345) stays List/Sync only (no create/delete — matches the honest surface); no change needed unless request params are added (optional: keep nuncio cal sync/nuncio cal list argument-free for now, matching the current CLI surface; document in Acceptance Criteria if you choose to add flags).
crates/nuncio-cli/src/runner.rs:
Replace the fabricated Commands::Cal handler (lines 345-361) with real connect_calendar_client() (mirror connect_mail_client, lines 1569-1581) + RPC calls, following the handle_sync/handle_list_folder shape (lines 683-729).
Delete/replace the now-wrong test assertions at lines ~1779-1798 (cal_list.contains(r#""events":[]"#)", cal_sync.contains(...)) — add a calendar_rpcs_round_trip_over_grpc_to_a_stub_daemon test mirroring mail_rpcs_round_trip_over_grpc_to_a_stub_daemon (lines 2094-2260+): a stub nuncio.v1.Calendar tonic service on an ephemeral port, driven by the real HeadlessRunner.
crates/nunciod/tests/spine_e2e_test.rs or a newcrates/nunciod/tests/calendar_e2e_test.rs — full-daemon offline E2E mirroring spine_e2e_test.rs's boot_daemon/full_mail_spine_round_trips_... shape (lines 87-133, 140+): boot the real daemon with an injected MockCalendarBackend via CalendarEngineOverrides, drive connect_calendar's Sync then ListEvents/GetEvent over the authenticated gRPC API, and assert an unauthenticated call is rejected (Code::Unauthenticated), mirroring grpc_auth_test.rs's pattern (lines 59-79).
Implementation steps
Add the proto service + messages; regenerate the golden; confirm cargo test -p nuncio-proto passes.
Add connect_calendar/AuthenticatedCalendarClient to nuncio-proto/src/client.rs with tests.
Add nunciod::calendar_sync::sync_with_backend + CalendarSyncError, with direct unit tests using MockCalendarBackend (no daemon/gRPC needed for these).
Add CalendarEngineOverrides + CalendarGrpcService + mount it in serve_on_listener_with_overrides behind its own BearerAuthInterceptor; add the auth-reject test in grpc.rs.
Wire the CLI: connect_calendar_client, real cal sync/cal list handlers, update/replace the stale fabricated-output tests, add the stub-daemon round-trip test.
Run the local gate (cargo fmt --all -- --check + cargo check-all + cargo test-all).
Acceptance criteria
nuncio.v1.Calendar service added: Sync, ListEvents, GetEvent RPCs + their request/response messages, listed above; golden regenerated; nuncio-proto's contract_stability::descriptor_matches_committed_golden test passes.
Calendar is mounted behind its own BearerAuthInterceptor in serve_on_listener_with_overrides; an offline E2E test asserts an unauthenticated call is rejected with Code::Unauthenticated.
ListEvents/GetEvent return only genuinely persisted events (via DatabaseEngine::list_calendar_events/get_calendar_event) — never fabricated data.
Sync with no injected backend returns an honest error (never a fabricated synced_count); with an injected MockCalendarBackend it genuinely fetches and persists events, verified end to end by ListEvents/GetEvent immediately after.
nuncio cal sync/nuncio cal list are real gRPC clients of the new service; the old fabricated "Calendar Events: 0 events found" / "calendar_sync_started" strings are gone from runner.rs.
Offline E2E (wiremock/MockCalendarBackend) proves Sync → ListEvents/GetEvent end to end over the authenticated gRPC API; no live network.
the local gate (cargo fmt --all -- --check + cargo check-all + cargo test-all) green (fmt + clippy -D warnings all-targets + test-all), 0 ignored tests.
Definition of done & process
Follow docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in .rs/.proto); mock-only tests. Branch story/m1-calendar-api off dev; PR to dev with the gate output pasted as evidence; the PO reviews before the issue is closed.
Dependencies / notes
[PROTO]: this story changes nuncio.proto and its golden. Confirm with the PO that no other proto story (currently also M2-S2) is in flight before starting; only one proto-touching story lands at a time.
M1-S2 ("Wire rrule recurrence") depends on ListEvents existing from this story — it should not start (or should coordinate on ordering) until this PR merges, since it wires occurrence-expansion into CalendarGrpcService::list_events.
Prior art: a patch wip-calendar-3b2-partial.patch may exist in the handoff materials — treat it as reference only, not trustworthy; re-implement fresh against this issue and the real current tree, and re-verify anything it suggests against crates/nuncio-cal and crates/nuncio-store/src/db.rs as they exist today (they may have diverged since that patch was drafted).
Real per-account CalDAV configuration (a caldav_url/credential per account, needed for Sync to ever do something real in production) is explicitly out of scope here and is a follow-up story — do not attempt to bolt it on.
Size: L.
## Context
Story for milestone M1. **Read [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md) first** — it defines the process, gates, and patterns; this issue defines only the specific work.
`crates/nuncio-cal` is a fully real, tested engine crate (RFC 5545 iCalendar parsing, a real `CalDavClient` REPORT transport, a `CalendarBackend` trait, `MockCalendarBackend`) — but it is completely disconnected from the running daemon. `nunciod/Cargo.toml:15` declares `nuncio-cal` as a dependency, yet `grep -r nuncio_cal crates/nunciod/src` returns **zero matches**: it is a genuinely orphaned crate. `nuncio-store::db::DatabaseEngine` already has working, tested calendar CRUD (`save_calendar_event`/`list_calendar_events`/`get_calendar_event`, `crates/nuncio-store/src/db.rs:918-1035`) that nothing ever calls in production. Meanwhile the CLI's `nuncio cal list`/`nuncio cal sync` (`crates/nuncio-cli/src/runner.rs:345-361`) fabricates its output: `list` always prints `"Calendar Events: 0 events found"` and `sync` only flips a local `EventBus` status flag via `CoreCommand::SyncAll` — no calendar backend is ever touched. This story wires all of that together into a real, authenticated gRPC vertical, the same way the `Mail` service already does for email.
## Goal
Add a `nuncio.v1.Calendar` gRPC service with an honest surface — `Sync`, `ListEvents`, `GetEvent` — backed by the daemon's real store and (for `Sync`, when injected) a real `CalendarBackend`. `ListEvents`/`GetEvent` MUST always read genuine persisted data (never a fabricated event). `Sync` MUST NOT fake a CalDAV write: there is currently no way to persist a per-account CalDAV collection URL/credential (`nuncio_core::AccountConfig` has no CalDAV fields), so the production (non-test) `Sync` path has nothing real to connect to yet — it must return an honest error (e.g. "no CalDAV configuration exists for this account") rather than pretending to sync. Only the test-injection seam (mirroring `MailEngineOverrides`) exercises a working, mocked `Sync`. **No create/delete RPCs** — this story does not add real CalDAV write support. The CLI's `cal list`/`cal sync` must become real thin gRPC clients, replacing the fabricated strings in `runner.rs:345-361`.
## Files to touch / patterns to mirror
Mirror the **Mail** vertical throughout — it is the fully-built reference for this exact shape:
- `crates/nuncio-proto/proto/nuncio/v1/nuncio.proto` — add `service Calendar` (mirror the `service Mail` doc comment and SECURITY note at lines 223-230) plus its messages. New messages:
- `message CalendarEvent` mirroring `nuncio_core::model::CalendarEvent` (`crates/nuncio-core/src/model.rs:104-122`) field-for-field: `id`, `account_id`, `calendar_id`, `summary`, `start_time` (`int64`), `end_time` (`int64`), `rrule` (`optional string`), `location` (`optional string`). (Named `CalendarEvent`, not `Event` — `Event` is already taken by `System`'s push-event wrapper at proto line 48.)
- `message CalendarSyncRequest { string account_id = 1; string calendar_id = 2; int64 start_window = 3; int64 end_window = 4; }` — carries `calendar_id`+window because `CalendarBackend::fetch_events` (see below) is inherently scoped that way, unlike `Mail`'s account-wide `Sync`.
- `message CalendarSyncResponse { uint64 synced_count = 1; }`
- `message ListEventsRequest { string account_id = 1; string calendar_id = 2; int64 start_window = 3; int64 end_window = 4; }`
- `message ListEventsResponse { repeated CalendarEvent events = 1; }`
- `message GetEventRequest { string event_id = 1; }`
- `message GetEventResponse { CalendarEvent event = 1; }`
- After editing, regenerate the golden per §5 of `STORY-WORKFLOW.md` (recipe is also printed by the failing test in `crates/nuncio-proto/src/lib.rs:97-116`).
- `crates/nuncio-proto/src/client.rs` — add `AuthenticatedCalendarClient` type alias + `connect_calendar(addr, token)`, mirroring `AuthenticatedMailClient`/`connect_mail` exactly (lines 96-98, 167-182). Add matching unit tests mirroring `connect_mail_fails_closed_on_invalid_token`/`connect_mail_reports_transport_error_when_daemon_unreachable` (lines 317-332).
- `crates/nunciod/Cargo.toml` — no change needed; `nuncio-cal` is already a dependency (line 15), just finally used.
- `crates/nunciod/src/lib.rs` — add `pub mod calendar_sync;` (alongside the existing `pub mod sync;` at line 8).
- `crates/nunciod/src/calendar_sync.rs` (**new file**) — mirror `crates/nunciod/src/sync.rs`'s shape (its whole doc comment explains the pattern). Add:
- `CalendarSyncError` enum (thiserror), mirroring `SyncError`.
- `pub async fn sync_with_backend(db: &DatabaseEngine, backend: &dyn CalendarBackend, calendar_id: &str, start_window: i64, end_window: i64) -> Result<usize, CalendarSyncError>` — calls `backend.fetch_events(calendar_id, start_window, end_window)`, then `db.save_calendar_event(&event)` for each result, returning the count. Pure fetch-and-persist, unit-testable directly with `MockCalendarBackend`, exactly like `sync.rs`'s `fetch_and_persist`.
- There is deliberately **no** `run_account_sync`/`run_all_accounts_sync` production entry point yet (mirroring `sync.rs`'s real-backend builders) — there is no persisted per-account CalDAV config to build a real `CalDavClient` from. Document this gap in a doc comment; a later story adds account-level CalDAV config and the real production entry point.
- `crates/nunciod/src/grpc.rs` — mirror the `Mail` wiring throughout:
- `CalendarEngineOverrides { pub calendar_backend: Option<Arc<dyn nuncio_cal::CalendarBackend>> }` (`Clone`, `Default`, custom `Debug`), mirroring `MailEngineOverrides` (lines 439-456).
- `CalendarGrpcService { db: Arc<DatabaseEngine>, overrides: CalendarEngineOverrides }` implementing a new `nuncio_proto::v1::calendar_server::Calendar` trait:
- `list_events`/`get_event`: always real — call `db.list_calendar_events(&req.account_id, req.start_window, req.end_window)` / `db.get_calendar_event(&req.event_id)` directly (mirror `MailGrpcService::list_messages`/`get_message`, lines 496-538), map `nuncio_core::model::CalendarEvent` <-> proto `CalendarEvent` (new `map_calendar_event_to_proto` helper, mirroring `map_email_to_proto`).
- `sync`: if `overrides.calendar_backend` is `Some`, call `calendar_sync::sync_with_backend`; if `None`, return `Status::internal("no CalDAV configuration exists for account '{account_id}'; per-account CalDAV configuration is not yet implemented")` — never a fabricated success (mirror the doc-comment style of `MailGrpcService::sync`, lines 646-677, but note production always errors today).
- Add `Calendar` to `serve_on_listener_with_overrides`'s signature (new `calendar_overrides: CalendarEngineOverrides` param) and mount it behind its OWN `BearerAuthInterceptor`, exactly like every other service (lines 1199-1219 for the `Mail`/`Filters` pattern) — update the function's doc comment's invariant list and `serve`/`serve_on_listener`'s call sites to pass `CalendarEngineOverrides::default()`.
- Add a `calendar_rpcs_reject_missing_bearer_token` test in `grpc.rs`'s own `#[cfg(test)] mod tests`, mirroring `mail_rpcs_reject_missing_bearer_token` (lines 1942-1974).
- `crates/nuncio-cli/src/args.rs` — `CalSubcommand` (lines 337-345) stays `List`/`Sync` only (no create/delete — matches the honest surface); no change needed unless request params are added (optional: keep `nuncio cal sync`/`nuncio cal list` argument-free for now, matching the current CLI surface; document in Acceptance Criteria if you choose to add flags).
- `crates/nuncio-cli/src/runner.rs`:
- Replace the fabricated `Commands::Cal` handler (lines 345-361) with real `connect_calendar_client()` (mirror `connect_mail_client`, lines 1569-1581) + RPC calls, following the `handle_sync`/`handle_list_folder` shape (lines 683-729).
- Delete/replace the now-wrong test assertions at lines ~1779-1798 (`cal_list.contains(r#""events":[]"#)"`, `cal_sync.contains(...)`) — add a `calendar_rpcs_round_trip_over_grpc_to_a_stub_daemon` test mirroring `mail_rpcs_round_trip_over_grpc_to_a_stub_daemon` (lines 2094-2260+): a stub `nuncio.v1.Calendar` tonic service on an ephemeral port, driven by the real `HeadlessRunner`.
- `crates/nunciod/tests/spine_e2e_test.rs` or a **new** `crates/nunciod/tests/calendar_e2e_test.rs` — full-daemon offline E2E mirroring `spine_e2e_test.rs`'s `boot_daemon`/`full_mail_spine_round_trips_...` shape (lines 87-133, 140+): boot the real daemon with an injected `MockCalendarBackend` via `CalendarEngineOverrides`, drive `connect_calendar`'s `Sync` then `ListEvents`/`GetEvent` over the authenticated gRPC API, and assert an unauthenticated call is rejected (`Code::Unauthenticated`), mirroring `grpc_auth_test.rs`'s pattern (lines 59-79).
## Implementation steps
1. Add the proto service + messages; regenerate the golden; confirm `cargo test -p nuncio-proto` passes.
2. Add `connect_calendar`/`AuthenticatedCalendarClient` to `nuncio-proto/src/client.rs` with tests.
3. Add `nunciod::calendar_sync::sync_with_backend` + `CalendarSyncError`, with direct unit tests using `MockCalendarBackend` (no daemon/gRPC needed for these).
4. Add `CalendarEngineOverrides` + `CalendarGrpcService` + mount it in `serve_on_listener_with_overrides` behind its own `BearerAuthInterceptor`; add the auth-reject test in `grpc.rs`.
5. Wire the CLI: `connect_calendar_client`, real `cal sync`/`cal list` handlers, update/replace the stale fabricated-output tests, add the stub-daemon round-trip test.
6. Add the full-daemon offline E2E (mock backend injected, unauth rejection asserted).
7. Run the local gate (`cargo fmt --all -- --check` + `cargo check-all` + `cargo test-all`).
## Acceptance criteria
- [ ] `nuncio.v1.Calendar` service added: `Sync`, `ListEvents`, `GetEvent` RPCs + their request/response messages, listed above; golden regenerated; `nuncio-proto`'s `contract_stability::descriptor_matches_committed_golden` test passes.
- [ ] `Calendar` is mounted behind its own `BearerAuthInterceptor` in `serve_on_listener_with_overrides`; an offline E2E test asserts an unauthenticated call is rejected with `Code::Unauthenticated`.
- [ ] `ListEvents`/`GetEvent` return only genuinely persisted events (via `DatabaseEngine::list_calendar_events`/`get_calendar_event`) — never fabricated data.
- [ ] `Sync` with no injected backend returns an honest error (never a fabricated `synced_count`); with an injected `MockCalendarBackend` it genuinely fetches and persists events, verified end to end by `ListEvents`/`GetEvent` immediately after.
- [ ] `nuncio cal sync`/`nuncio cal list` are real gRPC clients of the new service; the old fabricated `"Calendar Events: 0 events found"` / `"calendar_sync_started"` strings are gone from `runner.rs`.
- [ ] Offline E2E (wiremock/`MockCalendarBackend`) proves `Sync` → `ListEvents`/`GetEvent` end to end over the authenticated gRPC API; no live network.
- [ ] the local gate (`cargo fmt --all -- --check` + `cargo check-all` + `cargo test-all`) green (fmt + clippy `-D warnings` all-targets + test-all), 0 ignored tests.
## Definition of done & process
Follow [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md): no fabricated data; comment convention (intent-only, no story/GH/phase refs in `.rs`/`.proto`); mock-only tests. Branch `story/m1-calendar-api` off `dev`; PR to `dev` with the gate output pasted as evidence; the PO reviews before the issue is closed.
## Dependencies / notes
- **[PROTO]**: this story changes `nuncio.proto` and its golden. Confirm with the PO that no other proto story (currently also **M2-S2**) is in flight before starting; only one proto-touching story lands at a time.
- **M1-S2** ("Wire rrule recurrence") depends on `ListEvents` existing from this story — it should not start (or should coordinate on ordering) until this PR merges, since it wires occurrence-expansion into `CalendarGrpcService::list_events`.
- Prior art: a patch `wip-calendar-3b2-partial.patch` may exist in the handoff materials — treat it as reference only, **not** trustworthy; re-implement fresh against this issue and the real current tree, and re-verify anything it suggests against `crates/nuncio-cal` and `crates/nuncio-store/src/db.rs` as they exist today (they may have diverged since that patch was drafted).
- Real per-account CalDAV configuration (a `caldav_url`/credential per account, needed for `Sync` to ever do something real in production) is explicitly **out of scope** here and is a follow-up story — do not attempt to bolt it on.
- Size: L.
Delivered via PR #214 (merged to dev). Adversarial PO review passed — spec complete, auth invariant SAFE (Calendar behind its own BearerAuthInterceptor; E2E asserts Code::Unauthenticated), anti-fabrication CLEAN (honest Sync error, real reads). One deferral to M1-S2 (#186): ListEvents accepts calendar_id but doesn't filter on it (returns a documented account+window superset) — to be applied when #186 reworks list_events.
Delivered via PR #214 (merged to dev). Adversarial PO review passed — spec complete, auth invariant SAFE (Calendar behind its own BearerAuthInterceptor; E2E asserts Code::Unauthenticated), anti-fabrication CLEAN (honest Sync error, real reads). One deferral to M1-S2 (#186): ListEvents accepts calendar_id but doesn't filter on it (returns a documented account+window superset) — to be applied when #186 reworks list_events.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Context
Story for milestone M1. Read
docs/STORY-WORKFLOW.mdfirst — it defines the process, gates, and patterns; this issue defines only the specific work.crates/nuncio-calis a fully real, tested engine crate (RFC 5545 iCalendar parsing, a realCalDavClientREPORT transport, aCalendarBackendtrait,MockCalendarBackend) — but it is completely disconnected from the running daemon.nunciod/Cargo.toml:15declaresnuncio-calas a dependency, yetgrep -r nuncio_cal crates/nunciod/srcreturns zero matches: it is a genuinely orphaned crate.nuncio-store::db::DatabaseEnginealready has working, tested calendar CRUD (save_calendar_event/list_calendar_events/get_calendar_event,crates/nuncio-store/src/db.rs:918-1035) that nothing ever calls in production. Meanwhile the CLI'snuncio cal list/nuncio cal sync(crates/nuncio-cli/src/runner.rs:345-361) fabricates its output:listalways prints"Calendar Events: 0 events found"andsynconly flips a localEventBusstatus flag viaCoreCommand::SyncAll— no calendar backend is ever touched. This story wires all of that together into a real, authenticated gRPC vertical, the same way theMailservice already does for email.Goal
Add a
nuncio.v1.CalendargRPC service with an honest surface —Sync,ListEvents,GetEvent— backed by the daemon's real store and (forSync, when injected) a realCalendarBackend.ListEvents/GetEventMUST always read genuine persisted data (never a fabricated event).SyncMUST NOT fake a CalDAV write: there is currently no way to persist a per-account CalDAV collection URL/credential (nuncio_core::AccountConfighas no CalDAV fields), so the production (non-test)Syncpath has nothing real to connect to yet — it must return an honest error (e.g. "no CalDAV configuration exists for this account") rather than pretending to sync. Only the test-injection seam (mirroringMailEngineOverrides) exercises a working, mockedSync. No create/delete RPCs — this story does not add real CalDAV write support. The CLI'scal list/cal syncmust become real thin gRPC clients, replacing the fabricated strings inrunner.rs:345-361.Files to touch / patterns to mirror
Mirror the Mail vertical throughout — it is the fully-built reference for this exact shape:
crates/nuncio-proto/proto/nuncio/v1/nuncio.proto— addservice Calendar(mirror theservice Maildoc comment and SECURITY note at lines 223-230) plus its messages. New messages:message CalendarEventmirroringnuncio_core::model::CalendarEvent(crates/nuncio-core/src/model.rs:104-122) field-for-field:id,account_id,calendar_id,summary,start_time(int64),end_time(int64),rrule(optional string),location(optional string). (NamedCalendarEvent, notEvent—Eventis already taken bySystem's push-event wrapper at proto line 48.)message CalendarSyncRequest { string account_id = 1; string calendar_id = 2; int64 start_window = 3; int64 end_window = 4; }— carriescalendar_id+window becauseCalendarBackend::fetch_events(see below) is inherently scoped that way, unlikeMail's account-wideSync.message CalendarSyncResponse { uint64 synced_count = 1; }message ListEventsRequest { string account_id = 1; string calendar_id = 2; int64 start_window = 3; int64 end_window = 4; }message ListEventsResponse { repeated CalendarEvent events = 1; }message GetEventRequest { string event_id = 1; }message GetEventResponse { CalendarEvent event = 1; }STORY-WORKFLOW.md(recipe is also printed by the failing test incrates/nuncio-proto/src/lib.rs:97-116).crates/nuncio-proto/src/client.rs— addAuthenticatedCalendarClienttype alias +connect_calendar(addr, token), mirroringAuthenticatedMailClient/connect_mailexactly (lines 96-98, 167-182). Add matching unit tests mirroringconnect_mail_fails_closed_on_invalid_token/connect_mail_reports_transport_error_when_daemon_unreachable(lines 317-332).crates/nunciod/Cargo.toml— no change needed;nuncio-calis already a dependency (line 15), just finally used.crates/nunciod/src/lib.rs— addpub mod calendar_sync;(alongside the existingpub mod sync;at line 8).crates/nunciod/src/calendar_sync.rs(new file) — mirrorcrates/nunciod/src/sync.rs's shape (its whole doc comment explains the pattern). Add:CalendarSyncErrorenum (thiserror), mirroringSyncError.pub async fn sync_with_backend(db: &DatabaseEngine, backend: &dyn CalendarBackend, calendar_id: &str, start_window: i64, end_window: i64) -> Result<usize, CalendarSyncError>— callsbackend.fetch_events(calendar_id, start_window, end_window), thendb.save_calendar_event(&event)for each result, returning the count. Pure fetch-and-persist, unit-testable directly withMockCalendarBackend, exactly likesync.rs'sfetch_and_persist.run_account_sync/run_all_accounts_syncproduction entry point yet (mirroringsync.rs's real-backend builders) — there is no persisted per-account CalDAV config to build a realCalDavClientfrom. Document this gap in a doc comment; a later story adds account-level CalDAV config and the real production entry point.crates/nunciod/src/grpc.rs— mirror theMailwiring throughout:CalendarEngineOverrides { pub calendar_backend: Option<Arc<dyn nuncio_cal::CalendarBackend>> }(Clone,Default, customDebug), mirroringMailEngineOverrides(lines 439-456).CalendarGrpcService { db: Arc<DatabaseEngine>, overrides: CalendarEngineOverrides }implementing a newnuncio_proto::v1::calendar_server::Calendartrait:list_events/get_event: always real — calldb.list_calendar_events(&req.account_id, req.start_window, req.end_window)/db.get_calendar_event(&req.event_id)directly (mirrorMailGrpcService::list_messages/get_message, lines 496-538), mapnuncio_core::model::CalendarEvent<-> protoCalendarEvent(newmap_calendar_event_to_protohelper, mirroringmap_email_to_proto).sync: ifoverrides.calendar_backendisSome, callcalendar_sync::sync_with_backend; ifNone, returnStatus::internal("no CalDAV configuration exists for account '{account_id}'; per-account CalDAV configuration is not yet implemented")— never a fabricated success (mirror the doc-comment style ofMailGrpcService::sync, lines 646-677, but note production always errors today).Calendartoserve_on_listener_with_overrides's signature (newcalendar_overrides: CalendarEngineOverridesparam) and mount it behind its OWNBearerAuthInterceptor, exactly like every other service (lines 1199-1219 for theMail/Filterspattern) — update the function's doc comment's invariant list andserve/serve_on_listener's call sites to passCalendarEngineOverrides::default().calendar_rpcs_reject_missing_bearer_tokentest ingrpc.rs's own#[cfg(test)] mod tests, mirroringmail_rpcs_reject_missing_bearer_token(lines 1942-1974).crates/nuncio-cli/src/args.rs—CalSubcommand(lines 337-345) staysList/Synconly (no create/delete — matches the honest surface); no change needed unless request params are added (optional: keepnuncio cal sync/nuncio cal listargument-free for now, matching the current CLI surface; document in Acceptance Criteria if you choose to add flags).crates/nuncio-cli/src/runner.rs:Commands::Calhandler (lines 345-361) with realconnect_calendar_client()(mirrorconnect_mail_client, lines 1569-1581) + RPC calls, following thehandle_sync/handle_list_foldershape (lines 683-729).cal_list.contains(r#""events":[]"#)",cal_sync.contains(...)) — add acalendar_rpcs_round_trip_over_grpc_to_a_stub_daemontest mirroringmail_rpcs_round_trip_over_grpc_to_a_stub_daemon(lines 2094-2260+): a stubnuncio.v1.Calendartonic service on an ephemeral port, driven by the realHeadlessRunner.crates/nunciod/tests/spine_e2e_test.rsor a newcrates/nunciod/tests/calendar_e2e_test.rs— full-daemon offline E2E mirroringspine_e2e_test.rs'sboot_daemon/full_mail_spine_round_trips_...shape (lines 87-133, 140+): boot the real daemon with an injectedMockCalendarBackendviaCalendarEngineOverrides, driveconnect_calendar'sSyncthenListEvents/GetEventover the authenticated gRPC API, and assert an unauthenticated call is rejected (Code::Unauthenticated), mirroringgrpc_auth_test.rs's pattern (lines 59-79).Implementation steps
cargo test -p nuncio-protopasses.connect_calendar/AuthenticatedCalendarClienttonuncio-proto/src/client.rswith tests.nunciod::calendar_sync::sync_with_backend+CalendarSyncError, with direct unit tests usingMockCalendarBackend(no daemon/gRPC needed for these).CalendarEngineOverrides+CalendarGrpcService+ mount it inserve_on_listener_with_overridesbehind its ownBearerAuthInterceptor; add the auth-reject test ingrpc.rs.connect_calendar_client, realcal sync/cal listhandlers, update/replace the stale fabricated-output tests, add the stub-daemon round-trip test.cargo fmt --all -- --check+cargo check-all+cargo test-all).Acceptance criteria
nuncio.v1.Calendarservice added:Sync,ListEvents,GetEventRPCs + their request/response messages, listed above; golden regenerated;nuncio-proto'scontract_stability::descriptor_matches_committed_goldentest passes.Calendaris mounted behind its ownBearerAuthInterceptorinserve_on_listener_with_overrides; an offline E2E test asserts an unauthenticated call is rejected withCode::Unauthenticated.ListEvents/GetEventreturn only genuinely persisted events (viaDatabaseEngine::list_calendar_events/get_calendar_event) — never fabricated data.Syncwith no injected backend returns an honest error (never a fabricatedsynced_count); with an injectedMockCalendarBackendit genuinely fetches and persists events, verified end to end byListEvents/GetEventimmediately after.nuncio cal sync/nuncio cal listare real gRPC clients of the new service; the old fabricated"Calendar Events: 0 events found"/"calendar_sync_started"strings are gone fromrunner.rs.MockCalendarBackend) provesSync→ListEvents/GetEventend to end over the authenticated gRPC API; no live network.cargo fmt --all -- --check+cargo check-all+cargo test-all) green (fmt + clippy-D warningsall-targets + test-all), 0 ignored tests.Definition of done & process
Follow
docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in.rs/.proto); mock-only tests. Branchstory/m1-calendar-apioffdev; PR todevwith the gate output pasted as evidence; the PO reviews before the issue is closed.Dependencies / notes
nuncio.protoand its golden. Confirm with the PO that no other proto story (currently also M2-S2) is in flight before starting; only one proto-touching story lands at a time.ListEventsexisting from this story — it should not start (or should coordinate on ordering) until this PR merges, since it wires occurrence-expansion intoCalendarGrpcService::list_events.wip-calendar-3b2-partial.patchmay exist in the handoff materials — treat it as reference only, not trustworthy; re-implement fresh against this issue and the real current tree, and re-verify anything it suggests againstcrates/nuncio-calandcrates/nuncio-store/src/db.rsas they exist today (they may have diverged since that patch was drafted).caldav_url/credential per account, needed forSyncto ever do something real in production) is explicitly out of scope here and is a follow-up story — do not attempt to bolt it on.Delivered via PR #214 (merged to dev). Adversarial PO review passed — spec complete, auth invariant SAFE (Calendar behind its own BearerAuthInterceptor; E2E asserts Code::Unauthenticated), anti-fabrication CLEAN (honest Sync error, real reads). One deferral to M1-S2 (#186): ListEvents accepts calendar_id but doesn't filter on it (returns a documented account+window superset) — to be applied when #186 reworks list_events.