# Capability Spec: api-plugin-contacts **Package**: `@suaveplan/api-plugin-contacts` **Status**: ACTIVE (folded 2026-05-20 from migrate-api-plugin-contacts) ## ADDED Requirements ### Requirement: Plugin factory with validated configuration The package SHALL expose `createContactsPlugin(options?: ContactsPluginOptions): PluginDescriptor` that Zod-validates `options` at construction time and returns a typed `PluginDescriptor` declaring `dependencies: ["database", "auth"]` so the host plugin manager topo-sorts after `@suaveplan/api-plugin-database` and `@suaveplan/api-plugin-auth`. The descriptor SHALL surface the same identifiers as the upstream `1.0.0` API (`name: "contacts"`, `tags: ["contacts", "groups", "audience"]`) but ship at `0.1.0`. #### Scenario: factory rejects invalid options - **WHEN** caller passes `maxGroupDepth: -1` - **THEN** factory throws `ContactsApiError` with code `CONTACTS_API.INVALID_INPUT` carrying the `ZodError` as `cause` #### Scenario: descriptor exposes defaults - **WHEN** factory is called with no arguments - **THEN** descriptor `defaults` equals `{ routePrefix: "/", maxImportBatchSize: 1000, maxGroupDepth: 10, enforceGroupDepth: true }` **Verified by:** `src/plugin/plugin.test.ts::"createContactsPlugin returns a typed descriptor with default config"` AND `src/plugin/plugin.test.ts::"createContactsPlugin throws CONTACTS_API.INVALID_INPUT on invalid options"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"descriptor advertises name, dependencies, version, tags"` --- ### Requirement: Stable error-code registry The package SHALL export `CONTACTS_API_CODES` (built with `defineCodes("CONTACTS_API", […])`) and a `ContactsApiError extends SuaveplanError` class. Every failure path in the package SHALL raise a `ContactsApiError` carrying one of the 12 stable codes. #### Scenario: every registered code is thrown from production code - **WHEN** static analysis greps `throw new ContactsApiError\(CONTACTS_API_CODES\.` across `src/**/*.ts` (excluding tests) - **THEN** every member of `CONTACTS_API_CODES` appears at least once #### Scenario: error class preserves cause chain - **WHEN** repository raises with `cause: new Error("boom")` - **THEN** `error.cause instanceof Error` and `error.code === "CONTACTS_API."` **Verified by:** `src/error-codes/error-codes.test.ts::"every code is a member of the CONTACTS_API namespace"` AND `src/error-codes/error-codes.test.ts::"ContactsApiError preserves cause"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"every error code is thrown by at least one production path"` --- ### Requirement: OpenTelemetry instrument bundle emitted from production paths The package SHALL export `getContactsApiInstruments(): ContactsApiInstruments` and `resetContactsApiInstruments()` and SHALL emit `contacts_api.contact_writes_total`, `contacts_api.contact_reads_total`, `contacts_api.group_writes_total`, `contacts_api.membership_writes_total`, `contacts_api.import_duration_ms` (histogram) and `contacts_api.merge_duration_ms` (histogram) from the corresponding route handlers. #### Scenario: in-memory MeterProvider receives counter increments - **WHEN** integration test installs an `InMemoryMetricExporter`-backed `MeterProvider`, calls `resetContactsApiInstruments`, then triggers `POST /contacts` once - **THEN** the collected metrics include `contacts_api.contact_writes_total{outcome="success"}=1` **Verified by:** `src/otel/instruments.test.ts::"getContactsApiInstruments returns the six-instrument bundle"` AND `src/otel/instruments.test.ts::"resetContactsApiInstruments rebinds against a freshly installed MeterProvider"` AND `tests/integration/full-boot.test.ts::"emits contacts_api.contact_writes_total on POST /contacts"` --- ### Requirement: SurrealDB-backed store implementations The package SHALL export `SurrealContactRepository`, `SurrealGroupRepository`, and `SurrealMembershipRepository` implementing the genesis `@suaveplan/contacts` `ContactStore`, `GroupStore`, and `MembershipStore` interfaces exactly (including `generateId()`). Repositories SHALL accept a connected `SurrealAdapter` via the constructor and SHALL NOT open or close it. #### Scenario: ContactStore roundtrip - **WHEN** integration test calls `repo.create({ tenantId, displayName, channels })` then `repo.getById(id)` - **THEN** the returned `Contact` matches the created shape with `createdAt` / `updatedAt` as `Date` instances #### Scenario: GroupStore enforces maxGroupDepth - **WHEN** factory was called with `maxGroupDepth: 2, enforceGroupDepth: true`, the repository builds depth-0 → depth-1 → depth-2 groups, and then attempts to create a depth-3 child - **THEN** the create call throws `CONTACTS_API.GROUP_DEPTH_EXCEEDED` #### Scenario: MembershipStore add is idempotent through repository - **WHEN** caller invokes the `POST /groups/:id/members` route twice with the same contact id - **THEN** the second call returns `{ added: 0 }` (no `MEMBERSHIP_EXISTS` thrown — uniqueness handled at the route boundary by an existence pre-check) **Verified by:** `src/repositories/contact-repository.test.ts::"create + getById roundtrip against fake adapter"` AND `src/repositories/group-repository.test.ts::"createGroup raises GROUP_DEPTH_EXCEEDED past maxGroupDepth"` AND `src/repositories/membership-repository.test.ts::"add raises MEMBERSHIP_EXISTS on duplicate"` AND `tests/integration/full-boot.test.ts::"contact roundtrip against real SurrealDB"` AND `tests/integration/group-depth.test.ts::"createGroup raises GROUP_DEPTH_EXCEEDED when enforceGroupDepth is on"` --- ### Requirement: Typed routes with Zod boundaries The package SHALL mount `/contacts` and `/groups` route trees on the host `OpenAPIHono` via `@hono/zod-openapi`. Every route SHALL declare request and response Zod schemas and SHALL read the current `Principal` from `c.get("user")` (populated upstream by `@suaveplan/api-plugin-auth`'s `requireAuth()` middleware). Routes SHALL NOT use `as any` casts. #### Scenario: unauthenticated request is rejected - **WHEN** test posts to `/contacts` without an auth session - **THEN** response is 401 with `{ success: false, error: "Unauthorized" }` #### Scenario: principal without tenantId is rejected - **WHEN** test posts to `/contacts` with a `Principal` lacking `tenantId` - **THEN** response is 401 with `{ success: false, error: "Unauthorized" }` #### Scenario: well-formed contact create returns 201 + body - **WHEN** authenticated request posts a valid `CreateContactSchema` body - **THEN** response is 201 with `{ success: true, data: { id, … } }` **Verified by:** `src/routes/contacts/build-routes.test.ts::"POST /contacts rejects requests without a tenantId"` AND `src/routes/contacts/build-routes.test.ts::"POST /contacts returns 201 on a valid body"` AND `src/routes/groups/build-routes.test.ts::"GET /groups/:id/tree returns a typed GroupTreeNode"` AND `tests/integration/full-boot.test.ts::"full contact + group + membership roundtrip"` --- ### Requirement: Bulk-import endpoint honours maxImportBatchSize The package SHALL expose `POST /contacts/import` whose body is `{ contacts: CreateContactSchema[], skipDuplicates: boolean }` capped at `maxImportBatchSize`. The route SHALL Zod-reject batches larger than the cap with 400 and SHALL return `{ imported, skipped, errors: [{ index, error }] }` with `errors` populated per failing row. #### Scenario: oversized batch is rejected at the schema boundary - **WHEN** caller posts a batch of length `maxImportBatchSize + 1` - **THEN** response is 400 (Zod boundary) and no rows are created #### Scenario: per-row errors reported with original index - **WHEN** caller posts a batch where row index 2 violates a database constraint - **THEN** response body `data.errors` contains `{ index: 2, error: }` and `data.imported` equals the count of successful rows **Verified by:** `src/routes/contacts/build-routes.test.ts::"POST /contacts/import rejects oversized batches at the schema boundary"` AND `tests/integration/bulk-import.test.ts::"per-row errors are reported with the original index against real SurrealDB"` --- ### Requirement: Typed Hono ContextVariableMap and ServiceRegistry augmentation The package SHALL augment Hono's `ContextVariableMap` with `contactRepository`, `groupRepository`, `membershipRepository`, and `eventBus` slots, and SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface with `contactRepository`, `groupRepository`, `membershipRepository` slots so sibling plugins can resolve them via `ctx.getService(…)` without a cast. #### Scenario: type-level access compiles without `as` - **WHEN** consumer writes `const repo = c.get("contactRepository")` inside a Hono handler in TypeScript - **THEN** `repo` is typed as `SurrealContactRepository | undefined` **Verified by:** `src/context/context.test.ts::"ContextVariableMap exposes contactRepository slot typed as SurrealContactRepository"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"ServiceRegistry augments contactRepository, groupRepository, membershipRepository slots"` --- ### Requirement: Schema migration via bundled SurrealQL The package SHALL ship `surql/tables/{contact,contact_group,group_membership}.surql` and SHALL apply them on `onInit` either via the host `MigrationRunner` (when present in `ServiceRegistry`) or via direct `SurrealAdapter.execute` calls otherwise. Errors at apply time SHALL throw `CONTACTS_API.SCHEMA_APPLY_FAILED` with the underlying cause attached. #### Scenario: schema apply succeeds against fresh SurrealDB - **WHEN** plugin `onInit` runs against a fresh Surreal namespace/database - **THEN** the three tables exist and a subsequent `INFO FOR TABLE contact` succeeds #### Scenario: schema apply failure is wrapped - **WHEN** the apply step throws (simulated by closing the adapter before `onInit`) - **THEN** the `onInit` promise rejects with `CONTACTS_API.SCHEMA_APPLY_FAILED` **Verified by:** `src/plugin/on-init.test.ts::"onInit raises SCHEMA_APPLY_FAILED when execute throws"` AND `tests/integration/full-boot.test.ts::"onInit applies surql tables against real SurrealDB"`