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.)
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).
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
Confirm M2-S1 has merged (ContactsBackend/MockContactsBackend/store CRUD must exist).
Add the proto service + messages; regenerate the golden; confirm cargo test -p nuncio-proto passes.
Add connect_contacts/AuthenticatedContactsClient with tests.
Add nuncio-contacts as a nunciod dependency; add ContactsEngineOverrides/ContactsGrpcService; mount behind its own BearerAuthInterceptor; add the auth-reject test.
Wire the CLI: connect_contacts_client, real contact list/search/add handlers (deciding and documenting the search approach), the stub-daemon round-trip test.
Add the full-daemon offline E2E.
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.
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.
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.
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 M2. Read
docs/STORY-WORKFLOW.mdfirst — it defines the process, gates, and patterns; this issue defines only the specific work.This story assumes M2-S1 ("Contacts engine") has landed:
nuncio-contactshas a real CardDAVREPORTclient, aContactsBackendtrait +MockContactsBackend(mirroringnuncio_cal::CalendarBackend/MockCalendarBackend), andnuncio-store::db::DatabaseEnginehas realsave_contact/list_contacts/get_contactCRUD backed by a genuine SQLite table. None of that is wired into the running daemon yet —nuncioddoes not depend onnuncio-contactsat all today (unlikenuncio-cal, which is at least declared as a dependency even before M1-S1). The CLI'snuncio contact list/search/add(crates/nuncio-cli/src/runner.rs:371-434) still callsnuncio_contacts::ContactsDatabase::in_memory()directly — a fresh throwaway database on every process — so nothing persists across CLI invocations. This story is the exactContactscounterpart of what M1-S1 does forCalendar, and should be built the same way, mirroring the fully-built Mail vertical.Goal
Add a
nuncio.v1.ContactsgRPC service —Sync,ListContacts,GetContact— authenticated behind its ownBearerAuthInterceptor, backed by the daemon's real store (from M2-S1) and (forSync, when injected) a realContactsBackend.ListContacts/GetContactmust always return genuinely persisted contacts.Sync's production path has the same honest-error constraint as Calendar's:nuncio_core::AccountConfighas no CardDAV collection URL/credential fields, so there is no way to build a real per-accountCardDavClientyet — productionSync(no override injected) must return an honest error, not a fabricated success; only the test-injection seam works today. The CLI'scontact list/search/addcommands 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 gapdocs/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— addservice Contacts(mirror theservice Mail/service CalendarSECURITY doc-comment convention, e.g. proto lines 223-230) plus:message Contactmirroringnuncio_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(alloptional string),repeated ContactEmail emails,repeated ContactPhone phones,is_favorite(bool),interaction_count(uint64). (Timestampscreated_at/updated_at/last_interacted_atare 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; }, mirroringnuncio_contacts::models::ContactEmail/ContactPhone.message ContactsSyncRequest { string account_id = 1; }/message ContactsSyncResponse { uint64 synced_count = 1; }, mirroringSyncRequest/SyncResponse(proto lines ~62-74, 399+) — unlike Calendar,ContactsBackend::fetch_contactsis account-scoped (see M2-S1), so this can mirror Mail'sSyncshape 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; }.STORY-WORKFLOW.md(recipe also printed by the failing test incrates/nuncio-proto/src/lib.rs:97-116).crates/nuncio-proto/src/client.rs— addAuthenticatedContactsClient+connect_contacts(addr, token), mirroringconnect_mail/AuthenticatedMailClient(lines 96-98, 167-182) exactly, with matching unit tests (mirror lines 317-332).crates/nunciod/Cargo.toml— addnuncio-contacts = { path = "../nuncio-contacts" }(a genuinely new dependency —nunciodhas never depended on this crate before).crates/nunciod/src/grpc.rs:ContactsEngineOverrides { pub contacts_backend: Option<Arc<dyn nuncio_contacts::ContactsBackend>> }, mirroringMailEngineOverrides/CalendarEngineOverrides.ContactsGrpcService { db: Arc<DatabaseEngine>, overrides: ContactsEngineOverrides }implementingnuncio_proto::v1::contacts_server::Contacts:list_contacts/get_contact: always real, viadb.list_contacts()/db.get_contact(&req.contact_id)(from M2-S1), mapped to proto (newmap_contact_to_protohelper mirroringmap_email_to_proto).sync: ifoverrides.contacts_backendisSome, fetch viaContactsBackend::fetch_contacts(&req.account_id)and persist each viadb.save_contact; ifNone, returnStatus::internal("no CardDAV configuration exists for account '{account_id}'; per-account CardDAV configuration is not yet implemented").Contactsbehind its ownBearerAuthInterceptorinserve_on_listener_with_overrides, exactly like every other service; update the function's doc comment invariant list.contacts_rpcs_reject_missing_bearer_tokentest ingrpc.rs's own test module, mirroringmail_rpcs_reject_missing_bearer_token(lines 1942-1974).crates/nuncio-cli/src/args.rs—ContactSubcommandalready exists (List/Search/Add, lines 138-161) with the right shape; no change needed unless you add asyncverb (recommended, mirroringMailSubcommand::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 theCommands::Contacthandlers (lines 371-434, currentlynuncio_contacts::ContactsDatabase::in_memory()) with realconnect_contacts_client()(mirrorconnect_mail_client, lines 1569-1581) + RPC calls.ContactSubcommand::Searchhas no server-side search RPC in the surface above — either add one (ListContactswith an optional query filter — extend the proto message, staying additive) or implement client-side filtering overListContacts's full result; pick one explicitly and document the choice, don't silently drop the feature. Add acontacts_rpcs_round_trip_over_grpc_to_a_stub_daemontest mirroringmail_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 mirroringspine_e2e_test.rs's shape: boot the real daemon with an injectedMockContactsBackendviaContactsEngineOverrides, driveconnect_contacts'sSyncthenListContacts/GetContactover the authenticated gRPC API, and assert an unauthenticated call is rejected, mirroringgrpc_auth_test.rs(lines 59-79).Implementation steps
ContactsBackend/MockContactsBackend/store CRUD must exist).cargo test -p nuncio-protopasses.connect_contacts/AuthenticatedContactsClientwith tests.nuncio-contactsas anuncioddependency; addContactsEngineOverrides/ContactsGrpcService; mount behind its ownBearerAuthInterceptor; add the auth-reject test.connect_contacts_client, realcontact list/search/addhandlers (deciding and documenting the search approach), the stub-daemon round-trip test.cargo verify.Acceptance criteria
nuncio.v1.Contactsservice added:Sync,ListContacts,GetContactRPCs + messages listed above; golden regenerated;nuncio-proto'scontract_stability::descriptor_matches_committed_goldentest passes.Contactsis mounted behind its ownBearerAuthInterceptor; an offline E2E test asserts an unauthenticated call is rejected withCode::Unauthenticated.ListContacts/GetContactreturn only genuinely persisted contacts (via the daemon's real store from M2-S1) — never fabricated data, and never the old hardcoded CardDAV contact.Syncwith no injected backend returns an honest error; with an injectedMockContactsBackendit genuinely fetches and persists contacts, verified byListContacts/GetContactimmediately after.nuncio contact list/search/addare real gRPC clients; a contact added viacontact addis visible viacontact listin 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.MockContactsBackend) provesSync→ListContacts/GetContactend to end over the authenticated gRPC API; no live network.cargo verifygreen (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/m2-contacts-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 M1-S1) is in flight before starting; only one proto-touching story lands at a time.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.carddav_url/credential per account) is explicitly out of scope, same as Calendar — do not attempt to bolt it on.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.