Files
2026-05-27 23:29:53 +01:00

18 KiB

Capability Spec: contacts

Package: @suaveplan/contacts Status: ACCEPTED (originating change migrate-contacts, archived 2026-05-20)

Every requirement carries a **Verified by:** line.

ADDED Requirements

Requirement: Universal-tier purity

The package SHALL run unchanged in Bun, Node, and browser environments. The src/ tree SHALL NOT import from node:*, access window / document / navigator, or depend on any browser-only or Node-only package.

Scenario: tier-purity grep is empty

  • WHEN the spec-conformance test walks src/ and looks for from "node:, globalThis.document, navigator.userAgent, etc.
  • THEN zero offenders are reported

Verified by: src/contacts.spec-conformance.test.ts::"src tree contains no browser/Node-only globals"


Requirement: Stable error code registry

The package SHALL expose CONTACTS_CODES built via defineCodes("CONTACTS", [...]) and a ContactsError extends SuaveplanError<ContactsErrorCode> with optional contactId / groupId / tenantId discriminants. Every failure path in the package SHALL raise a ContactsError (never a bare Error).

Scenario: every code is constructible

  • WHEN the test instantiates new ContactsError(code, "msg", {...}) for every member of CONTACTS_CODES
  • THEN the resulting error narrows code to the namespaced string and exposes the discriminants

Scenario: every code is thrown from production

  • WHEN openspec-conformance greps src/ for every code in the registry
  • THEN each code appears in at least one non-test source file

Verified by: src/error-codes/error-codes.test.ts::"every registered code constructs a ContactsError with namespaced code" AND src/error-codes/error-codes.test.ts::"every code exposes its discriminants on the instance"


Requirement: Zod-validated input boundaries

Every public manager method SHALL validate its primary input via a Zod schema before performing any side effect. Validation failure SHALL raise ContactsError with code INVALID_INPUT (never a raw ZodError).

Scenario: invalid create input is rejected

  • WHEN ContactManager.create({ ...malformed }) is called with a missing required field
  • THEN a ContactsError with code CONTACTS.INVALID_INPUT is thrown and no contact is persisted

Scenario: invalid group create input is rejected

  • WHEN GroupManager.create({ name: "" }) is called
  • THEN ContactsError with code CONTACTS.INVALID_INPUT is thrown

Verified by: src/schemas/schemas.test.ts::"rejects malformed create-contact input" AND src/core/contact-manager/contact-manager.test.ts::"raises INVALID_INPUT when create input fails Zod validation" AND src/core/group-manager/group-manager.test.ts::"raises INVALID_INPUT when create input fails Zod validation"


Requirement: Injectable Clock and IdGenerator

ContactManager, GroupManager, MembershipManager, ContactMerger, InMemoryContactStore, InMemoryGroupStore, and InMemoryMembershipStore SHALL accept optional clock: Clock and idGenerator: IdGenerator injections from @suaveplan/types. Production paths MUST NOT call Date.now(), new Date(), or Math.random() directly. Defaults SHALL be () => Date.now() and a deterministic per-instance counter.

Scenario: FakeClock controls timestamps

  • WHEN an InMemoryContactStore is constructed with clock: () => 1_700_000_000_000
  • THEN every created/updated Contact.createdAt and updatedAt reports new Date(1_700_000_000_000)

Scenario: injected IdGenerator controls IDs

  • WHEN an InMemoryGroupStore is constructed with idGenerator: () => "fixed_id"
  • THEN store.generateId() returns "fixed_id" for every call

Verified by: src/stores/contact-store/contact-store.test.ts::"uses injected Clock for createdAt and updatedAt" AND src/stores/group-store/group-store.test.ts::"uses injected IdGenerator for generateId" AND src/stores/membership-store/membership-store.test.ts::"uses injected IdGenerator for membership IDs"


Requirement: GroupStore.generateId on the interface

The GroupStore interface SHALL expose generateId(): string as a public method. GroupManager.create SHALL call store.generateId() directly — no (store as InMemoryGroupStore).generateId cast.

Scenario: any conforming GroupStore can be passed

  • WHEN a custom GroupStore implementation is passed to GroupManager
  • THEN create() calls the store's generateId() and the resulting group carries that id

Verified by: src/core/group-manager/group-manager.test.ts::"GroupManager.create uses store.generateId for the new group's id"


Requirement: OpenTelemetry instrument bundle

The package SHALL expose getContactsInstruments() and resetContactsInstruments() that return a cached bundle of OTel instruments bound against the current global MeterProvider. The bundle SHALL include six counters (contacts.created_total, contacts.updated_total, contacts.deleted_total, contacts.merged_total, groups.created_total, memberships.added_total) and three histograms (contacts.import_duration_ms, contacts.export_duration_ms, audience.resolve_duration_ms). Every public mutating method SHALL emit on at least one instrument; every duration-bearing operation SHALL record on its histogram.

Scenario: counter increments after creation

  • WHEN a test installs an in-memory MeterProvider, calls resetContactsInstruments(), then creates a contact
  • THEN reading the metric snapshot shows contacts.created_total incremented by 1

Scenario: histogram records on audience.resolve

  • WHEN a test installs an in-memory MeterProvider, calls resetContactsInstruments(), then resolver.resolve(...)
  • THEN the metric snapshot includes a recorded value on audience.resolve_duration_ms

Verified by: src/otel/instruments.test.ts::"contacts.created_total increments after ContactManager.create" AND src/otel/instruments.test.ts::"audience.resolve_duration_ms records a sample after AudienceResolver.resolve"


Requirement: ContactManager CRUD with channel deduplication

ContactManager SHALL provide create, getById, update, delete, list, findByChannel, findByIds, addTag, removeTag, and addChannel. When deduplicateByChannel is true (default), create and update SHALL raise CONTACT_CHANNEL_EXISTS / CONTACT_CHANNEL_EXISTS_ON_OTHER if a channel (type+value) is already present in another contact in the same tenant. When false, duplicates are permitted.

Scenario: duplicate channel rejected under default policy

  • WHEN two contacts are created with the same email channel in the same tenant
  • THEN the second call raises ContactsError with code CONTACTS.CONTACT_CHANNEL_EXISTS

Scenario: duplicate channel accepted when disabled

  • WHEN the manager is constructed with deduplicateByChannel: false and two contacts are created with the same channel
  • THEN both creates succeed

Scenario: first channel auto-marked primary

  • WHEN create is called with a channels array whose entries all have primary: false
  • THEN the persisted contact's first channel has primary: true

Verified by: src/core/contact-manager/contact-manager.test.ts::"rejects duplicate channels with CONTACT_CHANNEL_EXISTS under default dedup policy" AND src/core/contact-manager/contact-manager.test.ts::"permits duplicate channels when deduplicateByChannel is false" AND src/core/contact-manager/contact-manager.test.ts::"promotes the first channel to primary when none are flagged"


Requirement: Hierarchical group management with path arithmetic

GroupManager SHALL support n-level nested groups via a hybrid adjacency-list + materialized-path strategy. create SHALL compute the new group's path from its parent's path + slugified name and set depth = parent.depth + 1. update SHALL re-slugify on name change and propagate the new path to all descendants. move SHALL block circular references with code GROUP_CIRCULAR_REF. delete SHALL cascade-delete descendants.

Scenario: child path derives from parent

  • WHEN a group "VIP" is created under parent "All Customers"
  • THEN its path is /all-customers/vip and depth is 1

Scenario: rename propagates to descendants

  • WHEN the parent is renamed
  • THEN every descendant's path is rewritten and updatedAt is bumped

Scenario: circular move blocked

  • WHEN move(parent.id, child.id) is attempted
  • THEN ContactsError with code CONTACTS.GROUP_CIRCULAR_REF is thrown

Verified by: src/core/group-manager/group-manager.test.ts::"derives child path and depth from parent on create" AND src/core/group-manager/group-manager.test.ts::"propagates renamed-parent path to all descendants" AND src/core/group-manager/group-manager.test.ts::"raises GROUP_CIRCULAR_REF when move would create a cycle" AND src/core/group-manager/group-manager.test.ts::"cascade-deletes descendants on delete"


Requirement: Many-to-many memberships with ancestor count propagation

MembershipManager SHALL provide addContactToGroup, removeContactFromGroup, addContactToMultipleGroups, addMultipleContactsToGroup, getGroupsForContact, getMembersOfGroup, isContactInGroup, removeAllMembershipsForContact, countMembersInGroup. Adding a membership SHALL increment the target group's contactCount and every ancestor's totalContactCount. Removing SHALL decrement (floor 0). Bulk operations SHALL swallow MEMBERSHIP_EXISTS collisions and continue.

Scenario: ancestor count propagation

  • WHEN a contact is added to a deeply-nested child group
  • THEN every ancestor's totalContactCount is incremented by 1

Scenario: bulk add tolerates duplicates

  • WHEN addMultipleContactsToGroup is called and one contact is already a member
  • THEN that contact is silently skipped and the rest are added

Verified by: src/core/membership-manager/membership-manager.test.ts::"propagates incremented totalContactCount up the ancestor chain" AND src/core/membership-manager/membership-manager.test.ts::"addMultipleContactsToGroup swallows MEMBERSHIP_EXISTS and continues"


Requirement: ContactMerger preserves channels, tags, metadata, memberships

ContactMerger.merge(primaryId, secondaryIds) SHALL produce a MergeResult whose mergedContact carries the union of all channels (deduped by type+value, secondary channels demoted from primary), the union of all tags, and the merged metadata (primary wins on key collision). All secondary memberships SHALL be transferred to the primary unless the primary is already a member; then secondaries SHALL be deleted.

Scenario: missing primary raises MERGE_PRIMARY_NOT_FOUND

  • WHEN merge("nope", ["sec1"]) is called
  • THEN ContactsError with code CONTACTS.MERGE_PRIMARY_NOT_FOUND is thrown

Scenario: missing secondary raises MERGE_SECONDARY_NOT_FOUND

  • WHEN one of the secondary ids does not exist
  • THEN ContactsError with code CONTACTS.MERGE_SECONDARY_NOT_FOUND is thrown

Scenario: channels deduplicated, secondary demoted from primary

  • WHEN primary has email A (primary), secondary has email B (primary)
  • THEN the merged contact has A (primary) and B (non-primary)

Verified by: src/core/contact-merger/contact-merger.test.ts::"merges channels, tags, and metadata into the primary" AND src/core/contact-merger/contact-merger.test.ts::"raises MERGE_PRIMARY_NOT_FOUND when the primary id does not exist" AND src/core/contact-merger/contact-merger.test.ts::"raises MERGE_SECONDARY_NOT_FOUND when any secondary id does not exist" AND src/core/contact-merger/contact-merger.test.ts::"transfers secondary memberships to the primary without duplicating existing memberships"


Requirement: AudienceResolver walks groups recursively with exclusions, dedup, filters

AudienceResolver.resolve(options) SHALL collect every descendant of every groupIds[i] via getDescendants, remove every descendant of excludeGroupIds[i], look up every contact via memberships, dedup, apply excludeContactIds, then filter by statusFilter and channelType. The returned AudienceResult SHALL report totalCount, groupsResolved, and duplicatesRemoved. previewCount SHALL return only the totalCount.

Scenario: nested groups resolve transitively

  • WHEN a parent has 1 contact and its child has 1 contact, and resolve({ groupIds: [parent.id] }) is called
  • THEN totalCount is 2 and groupsResolved is 2

Scenario: same contact in two groups deduplicated

  • WHEN a contact is a member of two groups and both are in groupIds
  • THEN totalCount is 1 and duplicatesRemoved is 1

Scenario: channel filter applied

  • WHEN the audience has 1 email and 1 SMS contact and channelType: "email" is set
  • THEN totalCount is 1

Verified by: src/core/audience-resolver/audience-resolver.test.ts::"resolves contacts from a single group" AND src/core/audience-resolver/audience-resolver.test.ts::"resolves contacts from nested groups transitively" AND src/core/audience-resolver/audience-resolver.test.ts::"deduplicates contacts that are members of multiple groups" AND src/core/audience-resolver/audience-resolver.test.ts::"filters by channelType" AND src/core/audience-resolver/audience-resolver.test.ts::"filters by status" AND src/core/audience-resolver/audience-resolver.test.ts::"previewCount returns only the totalCount"


Requirement: Tree utilities for hierarchical display

The package SHALL expose buildGroupTree, flattenGroupTree, getAncestorPaths, validateNoCircularRef, countTreeContacts, findInTree, collectAllGroupIds as pure functions over Group[] / GroupTreeNode[]. None of these SHALL call Date.now() or perform IO.

Scenario: round-trip build/flatten

  • WHEN a flat group list is passed to buildGroupTree, then the resulting tree to flattenGroupTree
  • THEN the flattened nodes carry correct level, hasChildren, isLastChild

Verified by: src/tree/tree-utils.test.ts::"buildGroupTree assembles roots and children from a flat list" AND src/tree/tree-utils.test.ts::"flattenGroupTree reports level, hasChildren, isLastChild" AND src/tree/tree-utils.test.ts::"getAncestorPaths returns every prefix of a slash-separated path" AND src/tree/tree-utils.test.ts::"validateNoCircularRef returns false when the cycle would close" AND src/tree/tree-utils.test.ts::"countTreeContacts sums recursively" AND src/tree/tree-utils.test.ts::"findInTree returns null when missing and the node when present" AND src/tree/tree-utils.test.ts::"collectAllGroupIds enumerates every node in DFS order"


Requirement: CSV and JSON export with field selection

ContactExporter SHALL expose exportToJson, exportToCsv, and a unified export(contacts, options). The CSV output SHALL escape commas and double-quotes per RFC 4180. The exporter SHALL respect includeChannels, includeTags, includeMetadata flags and record an OTel histogram sample of contacts.export_duration_ms.

Scenario: CSV escapes embedded comma

  • WHEN a contact's display name contains a comma
  • THEN the CSV cell is wrapped in double quotes

Scenario: empty input

  • WHEN exportToCsv([]) is called
  • THEN the output is the empty string

Verified by: src/import-export/contact-exporter/contact-exporter.test.ts::"escapes embedded commas and double quotes in CSV output" AND src/import-export/contact-exporter/contact-exporter.test.ts::"returns empty string when there are no contacts" AND src/import-export/contact-exporter/contact-exporter.test.ts::"export dispatches to JSON or CSV based on options.format"


Requirement: CSV import with field mapping, default tags, dedup

ContactImporter.importFromCsv(csv, options) SHALL parse the CSV (RFC 4180-style with quoted fields), apply options.fieldMapping to construct CreateContactInput, append options.defaultTags, and (when skipDuplicates) check findByChannelValue before insert. Per-row errors SHALL be captured into ImportResult.errors with the 1-based row number. The importer SHALL record an OTel histogram sample of contacts.import_duration_ms.

Scenario: dedup skips existing channel

  • WHEN the source CSV contains a row whose email is already present and skipDuplicates: true
  • THEN the row is counted in skipped, not imported

Scenario: row with no resolvable channel is skipped

  • WHEN a row's mapped email and phone are both empty
  • THEN the row is counted in skipped

Verified by: src/import-export/contact-importer/contact-importer.test.ts::"imports records with field mapping and default tags" AND src/import-export/contact-importer/contact-importer.test.ts::"skips rows whose channel already exists when skipDuplicates is true" AND src/import-export/contact-importer/contact-importer.test.ts::"skips rows with no resolvable channel" AND src/import-export/contact-importer/contact-importer.test.ts::"captures per-row exceptions into ImportResult.errors with 1-based row"


Requirement: Public barrel + subpath exports

The package SHALL expose a single root barrel (src/index.ts) using explicit named re-exports (no export *). Every feature folder SHALL also be exposed via a subpath in package.json#exports so consumers can tree-shake. Feature-folder index.ts files SHALL use explicit named re-exports.

Scenario: every feature has a subpath

  • WHEN the conformance test reads package.json#exports
  • THEN every <feature>/<feature> subpath resolves to a real .ts source under src/

Scenario: barrel uses named exports only

  • WHEN the conformance test scans src/**/index.ts for export *
  • THEN zero offenders are reported

Verified by: src/contacts.spec-conformance.test.ts::"every feature index.ts uses explicit named exports" AND src/contacts.spec-conformance.test.ts::"every declared subpath export resolves to a real source file"