M2: Add Contacts API vertical (proto, daemon, CLI, E2E) #188

Closed
opened 2026-07-28 14:04:25 +00:00 by KofTwentyTwo · 1 comment
KofTwentyTwo commented 2026-07-28 14:04:25 +00:00 (Migrated from github.com)

Context

Story for milestone M2. Read docs/STORY-WORKFLOW.md first — it defines the process, gates, and patterns; this issue defines only the specific work.

This story assumes M2-S1 ("Contacts engine") has landed: nuncio-contacts has a real CardDAV REPORT client, a ContactsBackend trait + MockContactsBackend (mirroring nuncio_cal::CalendarBackend/MockCalendarBackend), and nuncio-store::db::DatabaseEngine has real save_contact/list_contacts/get_contact CRUD backed by a genuine SQLite table. None of that is wired into the running daemon yet — nunciod does not depend on nuncio-contacts at all today (unlike nuncio-cal, which is at least declared as a dependency even before M1-S1). The CLI's nuncio contact list/search/add (crates/nuncio-cli/src/runner.rs:371-434) still calls nuncio_contacts::ContactsDatabase::in_memory() directly — a fresh throwaway database on every process — so nothing persists across CLI invocations. This story is the exact Contacts counterpart of what M1-S1 does for Calendar, and should be built the same way, mirroring the fully-built Mail vertical.

Goal

Add a nuncio.v1.Contacts gRPC service — Sync, ListContacts, GetContact — authenticated behind its own BearerAuthInterceptor, backed by the daemon's real store (from M2-S1) and (for Sync, when injected) a real ContactsBackend. ListContacts/GetContact must always return genuinely persisted contacts. Sync's production path has the same honest-error constraint as Calendar's: nuncio_core::AccountConfig has no CardDAV collection URL/credential fields, so there is no way to build a real per-account CardDavClient yet — production Sync (no override injected) must return an honest error, not a fabricated success; only the test-injection seam works today. The CLI's contact list/search/add commands must become real thin gRPC clients of this service, so a contact added via the CLI actually survives past that process exiting — closing the exact gap docs/BACKLOG.md/the roadmap calls out for M2.

Files to touch / patterns to mirror

Mirror M1-S1's Calendar vertical (once it exists) and the Mail vertical throughout:

  • crates/nuncio-proto/proto/nuncio/v1/nuncio.proto — add service Contacts (mirror the service Mail/service Calendar SECURITY doc-comment convention, e.g. proto lines 223-230) plus:
    • message Contact mirroring nuncio_contacts::models::Contact (crates/nuncio-contacts/src/models.rs:4-22): id, account_id (optional string), display_name, given_name/family_name/organization/job_title/notes/avatar_url (all optional string), repeated ContactEmail emails, repeated ContactPhone phones, is_favorite (bool), interaction_count (uint64). (Timestamps created_at/updated_at/last_interacted_at are internal bookkeeping — include them only if a real client use case needs them; if omitted, say so in the PR rather than silently dropping fields nobody asked about.)
    • message ContactEmail { string email = 1; string label = 2; bool is_primary = 3; } / message ContactPhone { string phone = 1; string label = 2; bool is_primary = 3; }, mirroring nuncio_contacts::models::ContactEmail/ContactPhone.
    • message ContactsSyncRequest { string account_id = 1; } / message ContactsSyncResponse { uint64 synced_count = 1; }, mirroring SyncRequest/SyncResponse (proto lines ~62-74, 399+) — unlike Calendar, ContactsBackend::fetch_contacts is account-scoped (see M2-S1), so this can mirror Mail's Sync shape directly, not Calendar's calendar_id-scoped one.
    • message ListContactsRequest {} / message ListContactsResponse { repeated Contact contacts = 1; }.
    • message GetContactRequest { string contact_id = 1; } / message GetContactResponse { Contact contact = 1; }.
    • Regenerate the golden per §5 of STORY-WORKFLOW.md (recipe also printed by the failing test in crates/nuncio-proto/src/lib.rs:97-116).
  • crates/nuncio-proto/src/client.rs — add AuthenticatedContactsClient + connect_contacts(addr, token), mirroring connect_mail/AuthenticatedMailClient (lines 96-98, 167-182) exactly, with matching unit tests (mirror lines 317-332).
  • crates/nunciod/Cargo.toml — add nuncio-contacts = { path = "../nuncio-contacts" } (a genuinely new dependency — nunciod has never depended on this crate before).
  • crates/nunciod/src/grpc.rs:
    • ContactsEngineOverrides { pub contacts_backend: Option<Arc<dyn nuncio_contacts::ContactsBackend>> }, mirroring MailEngineOverrides/CalendarEngineOverrides.
    • ContactsGrpcService { db: Arc<DatabaseEngine>, overrides: ContactsEngineOverrides } implementing nuncio_proto::v1::contacts_server::Contacts:
      • list_contacts/get_contact: always real, via db.list_contacts()/db.get_contact(&req.contact_id) (from M2-S1), mapped to proto (new map_contact_to_proto helper mirroring map_email_to_proto).
      • sync: if overrides.contacts_backend is Some, fetch via ContactsBackend::fetch_contacts(&req.account_id) and persist each via db.save_contact; if None, return Status::internal("no CardDAV configuration exists for account '{account_id}'; per-account CardDAV configuration is not yet implemented").
    • Mount Contacts behind its own BearerAuthInterceptor in serve_on_listener_with_overrides, exactly like every other service; update the function's doc comment invariant list.
    • Add a contacts_rpcs_reject_missing_bearer_token test in grpc.rs's own test module, mirroring mail_rpcs_reject_missing_bearer_token (lines 1942-1974).
  • crates/nuncio-cli/src/args.rs — ContactSubcommand already exists (List/Search/Add, lines 138-161) with the right shape; no change needed unless you add a sync verb (recommended, mirroring MailSubcommand::Sync/CalSubcommand::Sync — note the addition explicitly in the PR if you do, since it's a new user-facing verb).
  • crates/nuncio-cli/src/runner.rs — replace the Commands::Contact handlers (lines 371-434, currently nuncio_contacts::ContactsDatabase::in_memory()) with real connect_contacts_client() (mirror connect_mail_client, lines 1569-1581) + RPC calls. ContactSubcommand::Search has no server-side search RPC in the surface above — either add one (ListContacts with an optional query filter — extend the proto message, staying additive) or implement client-side filtering over ListContacts's full result; pick one explicitly and document the choice, don't silently drop the feature. Add a contacts_rpcs_round_trip_over_grpc_to_a_stub_daemon test mirroring mail_rpcs_round_trip_over_grpc_to_a_stub_daemon (lines 2094-2260+).
  • crates/nunciod/tests/contacts_e2e_test.rs (new file) — full-daemon offline E2E mirroring spine_e2e_test.rs's shape: boot the real daemon with an injected MockContactsBackend via ContactsEngineOverrides, drive connect_contacts's Sync then ListContacts/GetContact over the authenticated gRPC API, and assert an unauthenticated call is rejected, mirroring grpc_auth_test.rs (lines 59-79).

Implementation steps

  1. Confirm M2-S1 has merged (ContactsBackend/MockContactsBackend/store CRUD must exist).
  2. Add the proto service + messages; regenerate the golden; confirm cargo test -p nuncio-proto passes.
  3. Add connect_contacts/AuthenticatedContactsClient with tests.
  4. Add nuncio-contacts as a nunciod dependency; add ContactsEngineOverrides/ContactsGrpcService; mount behind its own BearerAuthInterceptor; add the auth-reject test.
  5. Wire the CLI: connect_contacts_client, real contact list/search/add handlers (deciding and documenting the search approach), the stub-daemon round-trip test.
  6. Add the full-daemon offline E2E.
  7. Run cargo verify.

Acceptance criteria

  • nuncio.v1.Contacts service added: Sync, ListContacts, GetContact RPCs + messages listed above; golden regenerated; nuncio-proto's contract_stability::descriptor_matches_committed_golden test passes.
  • Contacts is mounted behind its own BearerAuthInterceptor; an offline E2E test asserts an unauthenticated call is rejected with Code::Unauthenticated.
  • ListContacts/GetContact return only genuinely persisted contacts (via the daemon's real store from M2-S1) — never fabricated data, and never the old hardcoded CardDAV contact.
  • Sync with no injected backend returns an honest error; with an injected MockContactsBackend it genuinely fetches and persists contacts, verified by ListContacts/GetContact immediately after.
  • nuncio contact list/search/add are real gRPC clients; a contact added via contact add is visible via contact list in a second, separate CLI invocation against the same daemon (proving real cross-process persistence, not just within one process) — assert this explicitly in the E2E.
  • Offline E2E (MockContactsBackend) proves Sync → ListContacts/GetContact end to end over the authenticated gRPC API; no live network.
  • cargo verify 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/m2-contacts-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 M1-S1) is in flight before starting; only one proto-touching story lands at a time.
  • Depends on M2-S1 (ContactsBackend, MockContactsBackend, and the store CRUD must exist first). Mirror M1-S1 for the daemon/proto/CLI wiring shape once it has landed — the two stories should look structurally identical modulo Calendar-vs-Contacts naming and the calendar_id-vs-account_id scoping difference noted above.
  • Real per-account CardDAV configuration (a carddav_url/credential per account) is explicitly out of scope, same as Calendar — do not attempt to bolt it on.
  • Size: L.
## Context Story for milestone M2. **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. This story assumes **M2-S1** ("Contacts engine") has landed: `nuncio-contacts` has a real CardDAV `REPORT` client, a `ContactsBackend` trait + `MockContactsBackend` (mirroring `nuncio_cal::CalendarBackend`/`MockCalendarBackend`), and `nuncio-store::db::DatabaseEngine` has real `save_contact`/`list_contacts`/`get_contact` CRUD backed by a genuine SQLite table. None of that is wired into the running daemon yet — `nunciod` does not depend on `nuncio-contacts` at all today (unlike `nuncio-cal`, which is at least declared as a dependency even before M1-S1). The CLI's `nuncio contact list`/`search`/`add` (`crates/nuncio-cli/src/runner.rs:371-434`) still calls `nuncio_contacts::ContactsDatabase::in_memory()` directly — a fresh throwaway database on every process — so nothing persists across CLI invocations. This story is the exact `Contacts` counterpart of what **M1-S1** does for `Calendar`, and should be built the same way, mirroring the fully-built **Mail** vertical. ## Goal Add a `nuncio.v1.Contacts` gRPC service — `Sync`, `ListContacts`, `GetContact` — authenticated behind its own `BearerAuthInterceptor`, backed by the daemon's real store (from M2-S1) and (for `Sync`, when injected) a real `ContactsBackend`. `ListContacts`/`GetContact` must always return genuinely persisted contacts. `Sync`'s production path has the same honest-error constraint as Calendar's: `nuncio_core::AccountConfig` has no CardDAV collection URL/credential fields, so there is no way to build a real per-account `CardDavClient` yet — production `Sync` (no override injected) must return an honest error, not a fabricated success; only the test-injection seam works today. The CLI's `contact list`/`search`/`add` commands must become real thin gRPC clients of this service, so a contact added via the CLI actually survives past that process exiting — closing the exact gap `docs/BACKLOG.md`/the roadmap calls out for M2. ## Files to touch / patterns to mirror Mirror **M1-S1**'s Calendar vertical (once it exists) and the **Mail** vertical throughout: - `crates/nuncio-proto/proto/nuncio/v1/nuncio.proto` — add `service Contacts` (mirror the `service Mail`/`service Calendar` SECURITY doc-comment convention, e.g. proto lines 223-230) plus: - `message Contact` mirroring `nuncio_contacts::models::Contact` (`crates/nuncio-contacts/src/models.rs:4-22`): `id`, `account_id` (`optional string`), `display_name`, `given_name`/`family_name`/`organization`/`job_title`/`notes`/`avatar_url` (all `optional string`), `repeated ContactEmail emails`, `repeated ContactPhone phones`, `is_favorite` (`bool`), `interaction_count` (`uint64`). (Timestamps `created_at`/`updated_at`/`last_interacted_at` are internal bookkeeping — include them only if a real client use case needs them; if omitted, say so in the PR rather than silently dropping fields nobody asked about.) - `message ContactEmail { string email = 1; string label = 2; bool is_primary = 3; }` / `message ContactPhone { string phone = 1; string label = 2; bool is_primary = 3; }`, mirroring `nuncio_contacts::models::ContactEmail`/`ContactPhone`. - `message ContactsSyncRequest { string account_id = 1; }` / `message ContactsSyncResponse { uint64 synced_count = 1; }`, mirroring `SyncRequest`/`SyncResponse` (proto lines ~62-74, 399+) — unlike Calendar, `ContactsBackend::fetch_contacts` is account-scoped (see M2-S1), so this can mirror Mail's `Sync` shape directly, not Calendar's calendar_id-scoped one. - `message ListContactsRequest {}` / `message ListContactsResponse { repeated Contact contacts = 1; }`. - `message GetContactRequest { string contact_id = 1; }` / `message GetContactResponse { Contact contact = 1; }`. - Regenerate the golden per §5 of `STORY-WORKFLOW.md` (recipe also printed by the failing test in `crates/nuncio-proto/src/lib.rs:97-116`). - `crates/nuncio-proto/src/client.rs` — add `AuthenticatedContactsClient` + `connect_contacts(addr, token)`, mirroring `connect_mail`/`AuthenticatedMailClient` (lines 96-98, 167-182) exactly, with matching unit tests (mirror lines 317-332). - `crates/nunciod/Cargo.toml` — add `nuncio-contacts = { path = "../nuncio-contacts" }` (a genuinely new dependency — `nunciod` has never depended on this crate before). - `crates/nunciod/src/grpc.rs`: - `ContactsEngineOverrides { pub contacts_backend: Option<Arc<dyn nuncio_contacts::ContactsBackend>> }`, mirroring `MailEngineOverrides`/`CalendarEngineOverrides`. - `ContactsGrpcService { db: Arc<DatabaseEngine>, overrides: ContactsEngineOverrides }` implementing `nuncio_proto::v1::contacts_server::Contacts`: - `list_contacts`/`get_contact`: always real, via `db.list_contacts()`/`db.get_contact(&req.contact_id)` (from M2-S1), mapped to proto (new `map_contact_to_proto` helper mirroring `map_email_to_proto`). - `sync`: if `overrides.contacts_backend` is `Some`, fetch via `ContactsBackend::fetch_contacts(&req.account_id)` and persist each via `db.save_contact`; if `None`, return `Status::internal("no CardDAV configuration exists for account '{account_id}'; per-account CardDAV configuration is not yet implemented")`. - Mount `Contacts` behind its own `BearerAuthInterceptor` in `serve_on_listener_with_overrides`, exactly like every other service; update the function's doc comment invariant list. - Add a `contacts_rpcs_reject_missing_bearer_token` test in `grpc.rs`'s own test module, mirroring `mail_rpcs_reject_missing_bearer_token` (lines 1942-1974). - `crates/nuncio-cli/src/args.rs` — `ContactSubcommand` already exists (`List`/`Search`/`Add`, lines 138-161) with the right shape; no change needed unless you add a `sync` verb (recommended, mirroring `MailSubcommand::Sync`/`CalSubcommand::Sync` — note the addition explicitly in the PR if you do, since it's a new user-facing verb). - `crates/nuncio-cli/src/runner.rs` — replace the `Commands::Contact` handlers (lines 371-434, currently `nuncio_contacts::ContactsDatabase::in_memory()`) with real `connect_contacts_client()` (mirror `connect_mail_client`, lines 1569-1581) + RPC calls. `ContactSubcommand::Search` has no server-side search RPC in the surface above — either add one (`ListContacts` with an optional query filter — extend the proto message, staying additive) or implement client-side filtering over `ListContacts`'s full result; pick one explicitly and document the choice, don't silently drop the feature. Add a `contacts_rpcs_round_trip_over_grpc_to_a_stub_daemon` test mirroring `mail_rpcs_round_trip_over_grpc_to_a_stub_daemon` (lines 2094-2260+). - `crates/nunciod/tests/contacts_e2e_test.rs` (**new file**) — full-daemon offline E2E mirroring `spine_e2e_test.rs`'s shape: boot the real daemon with an injected `MockContactsBackend` via `ContactsEngineOverrides`, drive `connect_contacts`'s `Sync` then `ListContacts`/`GetContact` over the authenticated gRPC API, and assert an unauthenticated call is rejected, mirroring `grpc_auth_test.rs` (lines 59-79). ## Implementation steps 1. Confirm M2-S1 has merged (`ContactsBackend`/`MockContactsBackend`/store CRUD must exist). 2. Add the proto service + messages; regenerate the golden; confirm `cargo test -p nuncio-proto` passes. 3. Add `connect_contacts`/`AuthenticatedContactsClient` with tests. 4. Add `nuncio-contacts` as a `nunciod` dependency; add `ContactsEngineOverrides`/`ContactsGrpcService`; mount behind its own `BearerAuthInterceptor`; add the auth-reject test. 5. Wire the CLI: `connect_contacts_client`, real `contact list`/`search`/`add` handlers (deciding and documenting the search approach), the stub-daemon round-trip test. 6. Add the full-daemon offline E2E. 7. Run `cargo verify`. ## Acceptance criteria - [ ] `nuncio.v1.Contacts` service added: `Sync`, `ListContacts`, `GetContact` RPCs + messages listed above; golden regenerated; `nuncio-proto`'s `contract_stability::descriptor_matches_committed_golden` test passes. - [ ] `Contacts` is mounted behind its own `BearerAuthInterceptor`; an offline E2E test asserts an unauthenticated call is rejected with `Code::Unauthenticated`. - [ ] `ListContacts`/`GetContact` return only genuinely persisted contacts (via the daemon's real store from M2-S1) — never fabricated data, and never the old hardcoded CardDAV contact. - [ ] `Sync` with no injected backend returns an honest error; with an injected `MockContactsBackend` it genuinely fetches and persists contacts, verified by `ListContacts`/`GetContact` immediately after. - [ ] `nuncio contact list`/`search`/`add` are real gRPC clients; a contact added via `contact add` is visible via `contact list` in a **second, separate** CLI invocation against the same daemon (proving real cross-process persistence, not just within one process) — assert this explicitly in the E2E. - [ ] Offline E2E (`MockContactsBackend`) proves `Sync` → `ListContacts`/`GetContact` end to end over the authenticated gRPC API; no live network. - [ ] `cargo verify` 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/m2-contacts-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 **M1-S1**) is in flight before starting; only one proto-touching story lands at a time. - **Depends on M2-S1** (`ContactsBackend`, `MockContactsBackend`, and the store CRUD must exist first). Mirror **M1-S1** for the daemon/proto/CLI wiring shape once it has landed — the two stories should look structurally identical modulo Calendar-vs-Contacts naming and the calendar_id-vs-account_id scoping difference noted above. - Real per-account CardDAV configuration (a `carddav_url`/credential per account) is explicitly out of scope, same as Calendar — do not attempt to bolt it on. - Size: L.
KofTwentyTwo commented 2026-07-29 23:23:34 +00:00 (Migrated from github.com)

Delivered via PR #218 (merged to dev). Adversarial review PASS — auth SAFE, anti-fabrication CLEAN, proto additive. CreateContact ACCEPTED (required by this issue's own contact-add persistence AC; honest local-only, disclosed at every layer). LOW notes tracked as a follow-up. M2 (Contacts) complete.

Delivered via PR #218 (merged to dev). Adversarial review PASS — auth SAFE, anti-fabrication CLEAN, proto additive. CreateContact ACCEPTED (required by this issue's own contact-add persistence AC; honest local-only, disclosed at every layer). LOW notes tracked as a follow-up. M2 (Contacts) complete.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: KofTwentyTwo/nuncio#188