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 forfrom "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 ofCONTACTS_CODES - THEN the resulting error narrows
codeto the namespaced string and exposes the discriminants
Scenario: every code is thrown from production
- WHEN
openspec-conformancegrepssrc/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
ContactsErrorwith codeCONTACTS.INVALID_INPUTis thrown and no contact is persisted
Scenario: invalid group create input is rejected
- WHEN
GroupManager.create({ name: "" })is called - THEN
ContactsErrorwith codeCONTACTS.INVALID_INPUTis 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
InMemoryContactStoreis constructed withclock: () => 1_700_000_000_000 - THEN every created/updated
Contact.createdAtandupdatedAtreportsnew Date(1_700_000_000_000)
Scenario: injected IdGenerator controls IDs
- WHEN an
InMemoryGroupStoreis constructed withidGenerator: () => "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
GroupStoreimplementation is passed toGroupManager - THEN
create()calls the store'sgenerateId()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, callsresetContactsInstruments(), then creates a contact - THEN reading the metric snapshot shows
contacts.created_totalincremented by 1
Scenario: histogram records on audience.resolve
- WHEN a test installs an in-memory
MeterProvider, callsresetContactsInstruments(), thenresolver.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
emailchannel in the same tenant - THEN the second call raises
ContactsErrorwith codeCONTACTS.CONTACT_CHANNEL_EXISTS
Scenario: duplicate channel accepted when disabled
- WHEN the manager is constructed with
deduplicateByChannel: falseand two contacts are created with the same channel - THEN both creates succeed
Scenario: first channel auto-marked primary
- WHEN
createis called with a channels array whose entries all haveprimary: 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
pathis/all-customers/vipanddepthis 1
Scenario: rename propagates to descendants
- WHEN the parent is renamed
- THEN every descendant's
pathis rewritten andupdatedAtis bumped
Scenario: circular move blocked
- WHEN
move(parent.id, child.id)is attempted - THEN
ContactsErrorwith codeCONTACTS.GROUP_CIRCULAR_REFis 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
totalContactCountis incremented by 1
Scenario: bulk add tolerates duplicates
- WHEN
addMultipleContactsToGroupis 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
ContactsErrorwith codeCONTACTS.MERGE_PRIMARY_NOT_FOUNDis thrown
Scenario: missing secondary raises MERGE_SECONDARY_NOT_FOUND
- WHEN one of the secondary ids does not exist
- THEN
ContactsErrorwith codeCONTACTS.MERGE_SECONDARY_NOT_FOUNDis 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
totalCountis 2 andgroupsResolvedis 2
Scenario: same contact in two groups deduplicated
- WHEN a contact is a member of two groups and both are in
groupIds - THEN
totalCountis 1 andduplicatesRemovedis 1
Scenario: channel filter applied
- WHEN the audience has 1 email and 1 SMS contact and
channelType: "email"is set - THEN
totalCountis 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 toflattenGroupTree - 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, notimported
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.tssource undersrc/
Scenario: barrel uses named exports only
- WHEN the conformance test scans
src/**/index.tsforexport * - 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"