@classytic/party 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,12 +1,16 @@
1
- import { OperationOptions, PaginationConfig, PluginType, Repository } from "@classytic/mongokit";
1
+ import { ModelBlueprint, OperationOptions, PaginationConfig, PluginType, Repository } from "@classytic/mongokit";
2
+ import { ResolvedTenantConfig, TenantConfig } from "@classytic/repo-core/tenant";
3
+ import { InProcessEventBus } from "@classytic/primitives/event-infra";
4
+ import { LegalForm } from "@classytic/primitives/legal-form";
2
5
  import { Connection, Model, Schema, SchemaDefinitionProperty, Types } from "mongoose";
3
6
  import { DomainEvent, EventHandler, EventMeta, EventTransport } from "@classytic/primitives/events";
7
+ import { ExternalRef, ExternalRef as ExternalRef$1, IdLike } from "@classytic/primitives/reference";
4
8
  import { z } from "zod";
5
9
  import { OutboxStore } from "@classytic/primitives/outbox";
6
10
  import { OperationContext } from "@classytic/primitives/context";
7
- import { ExternalRef, ExternalRef as ExternalRef$1 } from "@classytic/primitives/reference";
8
11
  import { PersonName, PersonName as PersonName$1 } from "@classytic/primitives/person";
9
12
  import { ContactAddress } from "@classytic/primitives/address";
13
+ import { SubjectRef } from "@classytic/primitives/subject";
10
14
  import { HttpError } from "@classytic/repo-core/errors";
11
15
  //#region src/domain/enums.d.ts
12
16
  /**
@@ -38,12 +42,31 @@ type PartyStatus = (typeof PARTY_STATUSES)[number];
38
42
  /** Channel kind for a contact point. Drives normalization + dedup keys. */
39
43
  declare const CONTACT_POINT_TYPES: readonly ['email', 'phone'];
40
44
  type ContactPointType = (typeof CONTACT_POINT_TYPES)[number];
45
+ /**
46
+ * Whether this channel may be used for MARKETING. Transactional messages (your
47
+ * booking is confirmed, your invoice is due) need no consent and are not what
48
+ * this records.
49
+ *
50
+ * Two states only, and `withdrawn` is deliberately not "absent": a channel that
51
+ * was never asked and a channel someone opted OUT of are the same silence to a
52
+ * sender but not to a regulator, and only the second one must survive a merge,
53
+ * a re-import, or the person booking again.
54
+ */
55
+ declare const CONSENT_STATUSES: readonly ['granted', 'withdrawn'];
56
+ type ConsentStatus = (typeof CONSENT_STATUSES)[number];
41
57
  /**
42
58
  * Address role. Mirrors Odoo's `type` (contact/invoice/delivery/other) but
43
59
  * as a first-class enum. A party carries many; `isDefault` picks the fallback.
44
60
  */
45
61
  declare const ADDRESS_TYPES: readonly ['billing', 'shipping', 'contact', 'other'];
46
62
  type AddressType = (typeof ADDRESS_TYPES)[number];
63
+ /**
64
+ * How a party is related to the reporting company (or to another party) — the related-party facts tax and
65
+ * disclosure rules read (IAS 24 in spirit). One party can hold several at once (a director who is also a
66
+ * shareholder); each is dated. A pack derives its own terms from these (a "sponsor shareholder").
67
+ */
68
+ declare const RELATIONSHIP_KINDS: readonly ['shareholder', 'director', 'key_management', 'partner_in_firm', 'member', 'employee', 'lessor', 'associate'];
69
+ type RelationshipKind = (typeof RELATIONSHIP_KINDS)[number];
47
70
  //#endregion
48
71
  //#region src/domain/value-objects.d.ts
49
72
  /**
@@ -65,11 +88,63 @@ interface PartyAddress extends ContactAddress {
65
88
  */
66
89
  interface ContactPoint {
67
90
  type: ContactPointType;
91
+ /**
92
+ * The CANONICAL form — a lowercased email, or an E.164 phone. Identity is the
93
+ * normalized value only; this is also what a notification transport should
94
+ * dial or mail, so a stored local spelling is a second bug waiting behind the
95
+ * dedup one.
96
+ */
68
97
  value: string;
69
98
  /** Normalized dedup key — the `identityKeys` index entry. */
70
99
  key: string;
100
+ /**
101
+ * What the operator actually TYPED, kept only when it differs from `value`.
102
+ * Support-facing provenance ("the till shows 01712345678, the invoice shows
103
+ * +8801712345678 — same number"). Never an identity signal: it is not keyed,
104
+ * not indexed and not matched on.
105
+ */
106
+ input?: string;
71
107
  isPrimary: boolean;
72
108
  verified: boolean;
109
+ /**
110
+ * Permission to MARKET to this channel, and the proof of it. Absent means
111
+ * never asked — which is a refusal to a sender, so treat it as one.
112
+ *
113
+ * Here rather than in a CRM package because consent is a property of the
114
+ * CHANNEL, exactly like `verified`: it is the answer to "may I mail this
115
+ * address", not a domain opinion about the person. Campaign subscriptions,
116
+ * sequences, suppression lists and unsubscribe tokens stay in CRM keyed by
117
+ * `partyId` — this is the one fact they all have to agree with, and a sender
118
+ * that disagrees with it is the bug this field exists to make visible.
119
+ *
120
+ * In a multi-tenant deployment a party row is already per-organization, so
121
+ * this is one business's permission and never a platform-wide one — which is
122
+ * what "specific and informed" requires.
123
+ */
124
+ consent?: ChannelConsent;
125
+ }
126
+ /**
127
+ * A consent decision, with what a regulator asks for when they ask you to
128
+ * prove it: what was decided, WHEN, and where the person was standing.
129
+ *
130
+ * Immutable by convention — a change of mind is a new record replacing this
131
+ * one, never an edit, so `at` always dates the CURRENT status.
132
+ */
133
+ interface ChannelConsent {
134
+ status: ConsentStatus;
135
+ /** The moment the person decided. */
136
+ at: Date;
137
+ /**
138
+ * The surface that asked — `'booking-form'`, `'dashboard'`, `'import:2019-mailchimp'`.
139
+ * Free text on purpose: the kernel cannot enumerate a host's screens, and an
140
+ * imported consent whose origin is unrecorded is one you cannot defend.
141
+ */
142
+ source?: string;
143
+ /**
144
+ * Caller IP at the moment of GRANT, when the host has one. Never backfilled:
145
+ * an IP attached later proves nothing and is then just retained PII.
146
+ */
147
+ ip?: string;
73
148
  }
74
149
  /**
75
150
  * Statutory tax identity. Country-agnostic slots — a BD deployment fills
@@ -177,13 +252,7 @@ interface PartyLogger {
177
252
  //#region src/models/tenant-field.d.ts
178
253
  type TenantFieldType = 'objectId' | 'string';
179
254
  interface SchemaBuildConfig {
180
- /** Schema type for the tenant column. Engine plumbs this everywhere. */
181
- tenantFieldType: TenantFieldType;
182
- /**
183
- * When `false`, models carry NO tenant column (company-wide identity). The
184
- * engine wires no `multiTenantPlugin` either. Default `true`.
185
- */
186
- multiTenant: boolean;
255
+ tenant: ResolvedTenantConfig;
187
256
  }
188
257
  /**
189
258
  * Tenant-column schema definition, or `undefined` when the engine is
@@ -192,6 +261,19 @@ interface SchemaBuildConfig {
192
261
  declare function tenantFieldDef(config: SchemaBuildConfig): SchemaDefinitionProperty | undefined;
193
262
  //#endregion
194
263
  //#region src/models/party.model.d.ts
264
+ interface PartyRelationship {
265
+ _id: Types.ObjectId;
266
+ kind: RelationshipKind;
267
+ /** The other party; absent means the reporting company itself. */
268
+ relatedTo?: Types.ObjectId | string | null;
269
+ /** Shareholding detail when `kind: 'shareholder'`. */
270
+ shareClass?: string | null;
271
+ sharePercent?: number | null;
272
+ from: Date;
273
+ /** Exclusive end; absent while it holds. */
274
+ to?: Date | null;
275
+ note?: string | null;
276
+ }
195
277
  interface IPartyDocument {
196
278
  _id: Types.ObjectId;
197
279
  /** Present only when the engine runs multi-tenant; absent for company-wide identity. */
@@ -214,6 +296,10 @@ interface IPartyDocument {
214
296
  /** Set when `status: 'merged'` — the survivor this party redirects to. */
215
297
  mergedInto?: Types.ObjectId | string;
216
298
  tags: string[];
299
+ /** What the law treats the party as (a sole trader is an `individual`). Absent: not stated. */
300
+ legalForm?: LegalForm;
301
+ /** Dated relationships to the reporting company or another party. */
302
+ relationships: PartyRelationship[];
217
303
  metadata?: Record<string, unknown>;
218
304
  /** Optimistic-concurrency counter (drives `claimVersion`). */
219
305
  version: number;
@@ -226,15 +312,17 @@ declare function buildPartySchema(config: SchemaBuildConfig): Schema<IPartyDocum
226
312
  interface PartyModels {
227
313
  Party: Model<IPartyDocument>;
228
314
  }
229
- /** Mongoose model names this package registers — drives the §21 collision check. */
230
- declare const PARTY_MODEL_NAMES: readonly ['Party'];
231
- interface CreateModelsOptions extends SchemaBuildConfig {
232
- /** On a name collision: `false` (default) throws; `true` deletes + re-registers (tests). */
315
+ interface DefinePartyModelsConfig extends SchemaBuildConfig {
316
+ /** On a name collision: `false` (default) throws; `true` re-registers (test/dev fixtures). */
233
317
  forceRecreate?: boolean;
234
318
  /** Mongoose auto-index builds. Default `true` (dev); `false` in prod (§35). */
235
319
  autoIndex?: boolean;
236
320
  }
237
- declare function createPartyModels(connection: Connection, config: CreateModelsOptions): PartyModels;
321
+ /**
322
+ * Describe the Party model set as a connection-free blueprint. Pure — builds no schema and
323
+ * registers nothing until `.bind(connection)`.
324
+ */
325
+ declare function definePartyModels(config: DefinePartyModelsConfig): ModelBlueprint<PartyModels>;
238
326
  //#endregion
239
327
  //#region src/events/dispatch.d.ts
240
328
  /** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
@@ -268,12 +356,45 @@ declare class PartyEventDispatcher {
268
356
  declare function flushPending(dispatcher: PartyEventDispatcher, queue: DomainEvent[], logger?: PartyLogger): Promise<void>;
269
357
  //#endregion
270
358
  //#region src/repositories/party.repository.d.ts
359
+ /** What a host asks `recordConsent` to write. */
360
+ interface RecordConsentInput {
361
+ channel: ContactPointType;
362
+ /** The channel as the person gave it; normalized here before it is matched. */
363
+ value: string;
364
+ status: ConsentStatus;
365
+ /** The surface that asked — see `ChannelConsent.source`. */
366
+ source?: string;
367
+ /** Caller IP. Kept only on a grant. */
368
+ ip?: string;
369
+ /**
370
+ * When they said it. Defaults to now; supply it only when replaying a real
371
+ * historical decision (a migration), never to backdate a fresh one.
372
+ */
373
+ at?: Date;
374
+ }
271
375
  interface PartyRepositoryDeps {
272
376
  events: PartyEventDispatcher;
273
377
  logger?: PartyLogger | undefined;
378
+ /**
379
+ * ISO 3166-1 alpha-2 region used to interpret phone input that carries no
380
+ * country code. Comes from `PartyShape.defaultPhoneRegion`, i.e. the
381
+ * deployment — this kernel names no country.
382
+ *
383
+ * Held on the repository rather than passed per call so `resolveOrCreate` and
384
+ * `syncIdentity` cannot disagree: a caller-supplied region would let the
385
+ * create path and the sync path key the same human differently, which is the
386
+ * split this whole change removes.
387
+ */
388
+ defaultPhoneRegion?: string | undefined;
389
+ /** Deployment-wide contact channels used as authoritative identity keys. */
390
+ identityContactTypes?: readonly ContactPointType[] | undefined;
274
391
  }
275
392
  declare class PartyRepository extends Repository<IPartyDocument> implements PartyPort<IPartyDocument> {
276
393
  private readonly events;
394
+ /** The ONE interpretation of free-form contact input in this deployment. */
395
+ private readonly normalize;
396
+ /** Contact data can exist without being strong enough to collapse two parties. */
397
+ private readonly identityContactTypes;
277
398
  constructor(model: Model<IPartyDocument>, deps: PartyRepositoryDeps, plugins?: PluginType[]);
278
399
  /** `update` that never returns null — the party was loaded first; a null means a concurrent delete. */
279
400
  private mustUpdate;
@@ -291,15 +412,46 @@ declare class PartyRepository extends Repository<IPartyDocument> implements Part
291
412
  syncIdentity(id: string, input: ResolvePartyInput, ctx: PartyContext): Promise<IPartyDocument>;
292
413
  addRole(id: string, role: PartyRole, ctx: PartyContext): Promise<IPartyDocument>;
293
414
  removeRole(id: string, role: PartyRole, ctx: PartyContext): Promise<IPartyDocument>;
415
+ /**
416
+ * Record a dated relationship (shareholder, director, lessor, …) to the reporting company or another
417
+ * party. Refused when one of the same kind with the same counterpart is already in force over the dates:
418
+ * two overlapping rows would double a shareholding, and only one of them could be ended.
419
+ */
420
+ addRelationship(id: string, input: AddRelationshipInput, ctx: PartyContext): Promise<IPartyDocument>;
421
+ /** End a relationship on a date (exclusive). Ending one already ended earlier is refused, never moved. */
422
+ endRelationship(id: string, relationshipId: string, to: Date, ctx: PartyContext): Promise<IPartyDocument>;
423
+ /** The relationships in force on a date — following a merged party to its survivor. */
424
+ relationshipsOn(id: string, on: Date, ctx: PartyContext): Promise<PartyRelationship[]>;
294
425
  linkUser(id: string, userId: string, ctx: PartyContext): Promise<IPartyDocument>;
426
+ /**
427
+ * Record what this person said about being MARKETED to on one channel.
428
+ *
429
+ * Addressed by the channel's raw value (what they typed) and normalized here,
430
+ * so a host passing `07700 900000` and a host passing `+447700900000` reach
431
+ * the same contact point — consent landing on a second, differently-spelled
432
+ * row is an opt-out that no sender would ever find.
433
+ *
434
+ * A channel the party does not hold is a typed error rather than a silent
435
+ * no-op: a grant that went nowhere looks identical to one that was stored,
436
+ * and the caller is usually a booking form that will never check again.
437
+ */
438
+ recordConsent(id: string, input: RecordConsentInput, ctx: PartyContext): Promise<IPartyDocument>;
295
439
  attachExternalRef(id: string, ref: ExternalRef, ctx: PartyContext): Promise<IPartyDocument>;
296
440
  archive(id: string, ctx: PartyContext): Promise<IPartyDocument>;
297
441
  restore(id: string, ctx: PartyContext): Promise<IPartyDocument>;
298
442
  /**
299
443
  * Merge `sourceId` into `targetId`: the survivor absorbs the loser's identity
300
- * keys, contact points, addresses, roles, external refs, and user links; the
301
- * loser becomes a terminal `merged` redirect (`mergedInto`). Two-document
302
- * write → runs in a transaction (falls back to sequential on standalone Mongo).
444
+ * keys, contact points, addresses, roles, external refs, user links, tags, and
445
+ * metadata, and FILLS its empty scalar slots (`taxIdentity`, `personName`,
446
+ * `legalName`, `parentId`) from the loser — never overwriting a populated
447
+ * survivor field. The loser becomes a terminal `merged` redirect
448
+ * (`mergedInto`). Two-document write → runs in a transaction (falls back to
449
+ * sequential on standalone Mongo).
450
+ *
451
+ * Concurrency: the loser is claimed `active|archived → merged` via CAS
452
+ * (`repo.claim`), so a second concurrent merge of the same source loses the
453
+ * race and surfaces a typed `PartyMergeError` — never a raw `E11000`. A
454
+ * duplicate-key collision while adopting the loser's keys maps to the same.
303
455
  */
304
456
  merge(sourceId: string, targetId: string, ctx: PartyContext): Promise<IPartyDocument>;
305
457
  /** Load a party by ObjectId hex, or throw the typed not-found (§P9-style). */
@@ -309,46 +461,27 @@ declare class PartyRepository extends Repository<IPartyDocument> implements Part
309
461
  /** Route a domain event with meta stamped from ctx + the party's org. */
310
462
  private dispatch;
311
463
  }
464
+ interface AddRelationshipInput {
465
+ kind: RelationshipKind;
466
+ /** The other party; omit for the reporting company. */
467
+ relatedTo?: string | null;
468
+ from: Date;
469
+ to?: Date | null;
470
+ shareClass?: string;
471
+ sharePercent?: number;
472
+ note?: string;
473
+ }
312
474
  //#endregion
313
475
  //#region src/engine/engine-types.d.ts
314
- interface PartyConfig {
315
- /** Mongoose connection — the package creates its model on this connection. */
316
- connection: Connection;
317
- /**
318
- * Multi-tenant scoping. Identity is FREQUENTLY company-wide (one customer is
319
- * the same person in every branch) — such hosts pass `false` and the model
320
- * carries no tenant column. SaaS / true-isolation hosts pass `true` (default).
321
- */
322
- multiTenant?: boolean;
323
- /**
324
- * Tenant column shape when `multiTenant` is on. `'objectId'` (default)
325
- * matches Better Auth orgs (unlocks `$lookup`/`.populate()`); `'string'`
326
- * matches UUID/slug hosts. Threaded into schema + plugin (§9.2).
327
- */
328
- tenantFieldType?: TenantFieldType;
329
- /** Arc-compatible event transport. Default: in-process bus. */
330
- eventTransport?: EventTransport;
331
- /** Transactional outbox (§P8). When wired, events persist in the write's session. */
332
- outbox?: OutboxStore;
333
- /** Logger surface for the in-process bus + repository. */
334
- logger?: PartyLogger;
335
- /** Pagination defaults for the repository (mongokit). */
336
- pagination?: PaginationConfig;
337
- /** Extra mongokit plugins applied after the defaults. */
338
- plugins?: PluginType[];
339
- /** On a model-name collision: `false` (default) throws; `true` re-registers (tests, §21). */
340
- forceRecreate?: boolean;
341
- /** Mongoose auto-index builds. Default `true` (dev); `false` in prod (§35). */
342
- autoIndex?: boolean;
343
- /** Downgrade the boot `transactions` capability requirement to a warning (standalone Mongo). */
344
- allowNonTransactional?: boolean;
345
- }
346
476
  interface ResolvedPartyConfig {
347
477
  connection: Connection;
348
- multiTenant: boolean;
349
- tenantFieldType: TenantFieldType;
478
+ tenant: Readonly<ResolvedTenantConfig>;
350
479
  forceRecreate: boolean;
351
480
  autoIndex: boolean;
481
+ /** ISO 3166-1 alpha-2 region for national-format phone input; unset ⇒ E.164 only. */
482
+ defaultPhoneRegion?: string;
483
+ /** Contact channels used as authoritative unique identity keys. */
484
+ identityContactTypes: readonly ContactPointType[];
352
485
  allowNonTransactional: boolean;
353
486
  logger?: PartyLogger;
354
487
  pagination?: PaginationConfig;
@@ -380,16 +513,141 @@ interface PartyEngine {
380
513
  withTransaction<T>(ctx: PartyContext, body: (txCtx: PartyContext) => Promise<T>): Promise<T>;
381
514
  /** Build/align indexes. Call once per deploy when booted `autoIndex: false` (§35). */
382
515
  syncIndexes(): Promise<void>;
383
- /** Release resources. Idempotent. */
384
- dispose(): Promise<void>;
516
+ /** Release resources owned by this engine. Idempotent. The kernel-standard lifecycle name. */
517
+ close(): Promise<void>;
385
518
  }
386
519
  //#endregion
387
- //#region src/engine/create-party.d.ts
388
- declare function createParty(config: PartyConfig): Promise<PartyEngine>;
520
+ //#region src/engine/define-party.d.ts
521
+ /**
522
+ * DESCRIBE-time shape — everything that determines the persistence SHAPE and needs no
523
+ * connection or I/O to inspect. Nothing here opens a socket or reads a secret.
524
+ */
525
+ interface PartyShape {
526
+ /** Company-wide identity (`false`, no tenant column) vs isolated (`true`, default). */
527
+ tenant?: boolean | TenantConfig;
528
+ /** Mongoose auto-index builds. Default `true` (dev); pass `false` in prod (§35). */
529
+ autoIndex?: boolean;
530
+ /**
531
+ * On a model-name collision: default throws; `true` re-registers (test/dev fixtures).
532
+ * Maps to mongokit's `replace` policy, which itself throws under `NODE_ENV=production`.
533
+ */
534
+ forceRecreate?: boolean;
535
+ /**
536
+ * ISO 3166-1 alpha-2 region used to read phone input that carries no country
537
+ * code — `'BD'` for a Bangladeshi deployment, `'GB'` for a British one. THIS
538
+ * KERNEL NAMES NO COUNTRY: the value is the deployment's, and belongs in
539
+ * `deployment.config.ts`.
540
+ *
541
+ * It is describe-time because it determines the persisted `identityKeys`, not
542
+ * just a request's interpretation — two boots with different regions would key
543
+ * the same human differently and fork the identity.
544
+ *
545
+ * Unset ⇒ only explicit E.164 phones are accepted; a national-format number is
546
+ * REFUSED rather than guessed at (FAIL LOUD rule 2). That is the safe default:
547
+ * before this option existed, a rejected parse silently degraded to
548
+ * `+${digits}`, and `01309000993` / `+8801309000993` became two parties for one
549
+ * member with nothing reporting it.
550
+ */
551
+ defaultPhoneRegion?: string;
552
+ /**
553
+ * Contact channels authoritative enough to resolve two records as the same
554
+ * party. Defaults to both `email` and `phone` for backward compatibility.
555
+ *
556
+ * Set `[]` when shared household/company contact details are candidate-match
557
+ * signals rather than proof of identity. Contacts are still normalized and
558
+ * stored; tax identifiers and external references remain identity keys.
559
+ *
560
+ * Persistence-shaping configuration: changing it does not rewrite existing
561
+ * `identityKeys`; migrate existing rows before changing a live deployment.
562
+ */
563
+ identityContactTypes?: readonly ContactPointType[];
564
+ }
565
+ /**
566
+ * BIND-time runtime collaborators — the live connection is the first `bind` arg; these are the
567
+ * rest. A provider client, transport, logger or store belongs here, not in the shape.
568
+ */
569
+ interface PartyRuntime {
570
+ eventTransport?: EventTransport;
571
+ outbox?: OutboxStore;
572
+ logger?: PartyLogger;
573
+ pagination?: PaginationConfig;
574
+ /** Downgrade the boot `transactions` capability requirement to a warning (standalone Mongo). */
575
+ allowNonTransactional?: boolean;
576
+ /** Extra mongokit plugins applied after the defaults. */
577
+ plugins?: PluginType[];
578
+ }
579
+ interface PartyBlueprint {
580
+ readonly id: 'party';
581
+ /** The model names this blueprint registers — known without a connection. */
582
+ readonly modelNames: readonly string[];
583
+ /**
584
+ * Compile + register the model on `connection`, wire the engine, verify capabilities.
585
+ * Synchronous: party's construction performs no async work (model registration and repo
586
+ * wiring are synchronous). The `KernelBlueprint` contract still permits async binds.
587
+ */
588
+ bind(connection: Connection, runtime?: PartyRuntime): PartyEngine;
589
+ }
590
+ /**
591
+ * Describe the party engine. Pure — registers no model, builds no schema, performs no I/O.
592
+ */
593
+ declare function defineParty(shape?: PartyShape): PartyBlueprint;
389
594
  //#endregion
390
595
  //#region src/repositories/repo-options.d.ts
391
596
  declare function repoOptionsFromCtx(ctx: PartyContext): OperationOptions;
392
597
  //#endregion
598
+ //#region src/domain/subject.d.ts
599
+ /**
600
+ * The canonical `subjectModel` value for a Party.
601
+ *
602
+ * Import this — never write `'Party'` in a host, a module, or a test.
603
+ */
604
+ declare const PARTY_SUBJECT_MODEL = "Party";
605
+ /**
606
+ * A Party as other kernels reference it.
607
+ *
608
+ * `subjectModel` is narrowed to the literal type so assigning a hand-typed
609
+ * string is a compile error at the call site, not a runtime mismatch discovered
610
+ * when a card fails to deactivate.
611
+ */
612
+ interface PartySubject extends SubjectRef {
613
+ /**
614
+ * Narrowed to the literal so assigning a hand-typed string is a COMPILE error
615
+ * at the call site, not a runtime mismatch discovered when a card fails to
616
+ * deactivate.
617
+ *
618
+ * Extends `SubjectRef` from `@classytic/primitives/subject` rather than
619
+ * re-declaring the pair: that primitive is the canonical shape for "who a record
620
+ * is about", shared by attendance, access, credentials and loyalty — and by the
621
+ * SDK, so a UI cannot bind a card to a different model than entitlements are
622
+ * granted against. (Deliberately NOT `ExternalRef`, which is the same shape for a
623
+ * different question — provenance, not identity. A grant carries both at once.)
624
+ */
625
+ subjectModel: typeof PARTY_SUBJECT_MODEL;
626
+ }
627
+ /**
628
+ * Build the subject pair for a Party.
629
+ *
630
+ * Accepts anything id-shaped (`ObjectId`, hex string, a document's `_id`) and
631
+ * stringifies through primitives' `idToString`, because the receiving kernels
632
+ * store `subjectRef` as a String — a raw `ObjectId` would stringify implicitly
633
+ * somewhere downstream and the two forms would not compare equal.
634
+ *
635
+ * Returns `null` for a missing id rather than a pair containing `"undefined"`.
636
+ * That string is a real hazard here: it is a *valid* `subjectRef` as far as the
637
+ * schema is concerned, so it persists, matches nothing, and looks like data.
638
+ */
639
+ declare function partySubject(id: IdLike | null | undefined): PartySubject | null;
640
+ /**
641
+ * Does this subject pair refer to a Party?
642
+ *
643
+ * For a resolver that fans a mixed subject stream out to the right kernel — an
644
+ * attendance list can hold Party members and, in an HR deployment, employees
645
+ * from another identity source.
646
+ */
647
+ declare function isPartySubject(subject: {
648
+ subjectModel?: string | undefined;
649
+ }): boolean;
650
+ //#endregion
393
651
  //#region src/domain/state-machine.d.ts
394
652
  /**
395
653
  * Party identity lifecycle FSM — the compile-time twin of the `repo.claim()`
@@ -404,20 +662,62 @@ declare function repoOptionsFromCtx(ctx: PartyContext): OperationOptions;
404
662
  */
405
663
  declare const PARTY_STATE_MACHINE: import("@classytic/primitives/state-machine").StateMachine<"active" | "archived" | "merged">;
406
664
  //#endregion
665
+ //#region src/domain/normalize.d.ts
666
+ /**
667
+ * How free-form contact input is interpreted. Threaded from the engine's
668
+ * resolved config so every path — `resolveOrCreate`, `syncIdentity`, merge —
669
+ * reads the same answer.
670
+ */
671
+ interface NormalizeOptions {
672
+ /**
673
+ * ISO 3166-1 alpha-2 region applied to phone input that carries no country
674
+ * code (`01712345678` on a Bangladeshi till). Omitted ⇒ E.164 only.
675
+ */
676
+ defaultRegion?: string | undefined;
677
+ }
678
+ /**
679
+ * Canonical email for the dedup key. Trim + lowercase, and deliberately NOT
680
+ * gmail dot / `+tag` folding — `a+shop@x.com` is a real, distinct inbox, and
681
+ * folding it MERGES two people, which is the irreversible direction.
682
+ *
683
+ * The rule moved to `@classytic/contact/email` unchanged for the canonical case,
684
+ * with one addition: an address that cannot be canonicalised now THROWS instead of
685
+ * becoming an identity key. Same reasoning as the phone side — a malformed address
686
+ * still makes a perfectly stable key, so admitting it does not fail, it forks.
687
+ */
688
+ declare function normalizeEmail(raw: string): string;
689
+ /** The stable dedup key for a contact point (`email:ada@x.com`, `phone:+8801...`). */
690
+ declare function contactPointKey(type: ContactPointType, value: string, options?: NormalizeOptions): string;
691
+ /** A tax-identity dedup key (`vat:...` / `bin:...`) — cross-scope-stable statutory id. */
692
+ declare function taxIdentityKey(kind: 'vat' | 'bin', value: string): string;
693
+ //#endregion
407
694
  //#region src/domain/identity.d.ts
695
+ interface IdentityKeyOptions {
696
+ /** Contact channels authoritative enough to resolve two records as one party. Default: both. */
697
+ contactTypes?: readonly ContactPointType[];
698
+ }
408
699
  /** De-duplicate an array preserving first-seen order. */
409
700
  declare function dedupe<T>(arr: readonly T[]): T[];
410
701
  /**
411
702
  * Turn the input's `emails[]` / `phones[]` into normalized `ContactPoint`s. The
412
703
  * first email and first phone are marked primary; blanks are skipped.
704
+ *
705
+ * THE SEAM. Both `resolveOrCreate` and `syncIdentity` funnel here, so this is
706
+ * the one place normalization can be enforced — a fix applied at N call sites
707
+ * is the same bug waiting at N+1.
708
+ *
709
+ * Two spellings of one number in a single input collapse to ONE point (they
710
+ * share a key). An unparseable phone THROWS rather than being skipped: skipping
711
+ * would create a party with no phone identity at all, which the next
712
+ * well-formed visit would not resolve onto — a silent fork by a different route.
413
713
  */
414
- declare function buildContactPoints(input: ResolvePartyInput): ContactPoint[];
714
+ declare function buildContactPoints(input: ResolvePartyInput, options?: NormalizeOptions): ContactPoint[];
415
715
  /**
416
716
  * The de-dup keys a party is resolved on: normalized emails/phones, VAT/BIN
417
717
  * statutory ids, and external-store refs. Blank/degenerate keys are dropped so
418
718
  * they never poison the unique index.
419
719
  */
420
- declare function deriveIdentityKeys(contactPoints: readonly ContactPoint[], input: ResolvePartyInput): string[];
720
+ declare function deriveIdentityKeys(contactPoints: readonly ContactPoint[], input: ResolvePartyInput, options?: IdentityKeyOptions): string[];
421
721
  /** Best display name from the available signals — never empty. */
422
722
  declare function resolveDisplayName(input: ResolvePartyInput, contactPoints: readonly ContactPoint[]): string;
423
723
  /** The primary (or first) contact-point value of a channel type. */
@@ -428,29 +728,30 @@ declare function pickPrimary(contactPoints: readonly ContactPoint[], type: 'emai
428
728
  declare function unionStrings(a: readonly string[], b: readonly string[]): string[];
429
729
  /** Union external refs by `(sourceModel, sourceId)` identity. */
430
730
  declare function unionRefs(a: readonly ExternalRef[], b: readonly ExternalRef[]): ExternalRef[];
731
+ /**
732
+ * Reconcile two consent records for the SAME channel.
733
+ *
734
+ * A withdrawal always beats a grant, regardless of which is newer. Recency is
735
+ * the intuitive rule and it is the wrong one here: the two records being
736
+ * reconciled came from duplicate rows whose clocks record when each row was
737
+ * TOUCHED, not the order the person actually spoke, so letting the later
738
+ * timestamp win means a merge can quietly re-subscribe someone who opted out.
739
+ * Being over-cautious costs a marketing email; being wrong costs a complaint
740
+ * you cannot defend. Between two records of the same status the later one wins,
741
+ * since only its timestamp is then load-bearing.
742
+ */
743
+ declare function reconcileConsent(a: ChannelConsent | undefined, b: ChannelConsent | undefined): ChannelConsent | undefined;
431
744
  /**
432
745
  * Union contact points by their normalized `key`. The survivor keeps its own
433
746
  * primaries; absorbed points come in as non-primary (a merge must never create
434
747
  * two primaries of the same channel).
748
+ *
749
+ * Where both sides hold the same channel the survivor's row is kept but its
750
+ * CONSENT is reconciled — dropping the loser's row wholesale would discard an
751
+ * opt-out that the duplicate was the only carrier of.
435
752
  */
436
753
  declare function unionContacts(a: readonly ContactPoint[], b: readonly ContactPoint[]): ContactPoint[];
437
754
  //#endregion
438
- //#region src/domain/normalize.d.ts
439
- /** Lowercase + trim an email. Conservative — never strips dots/plus tags. */
440
- declare function normalizeEmail(raw: string): string;
441
- /**
442
- * Canonical phone form for dedup. Uses primitives' `parsePhone`: a valid E.164
443
- * input collapses to its canonical `+<cc><national>`; anything the strict parser
444
- * rejects (local formats without a country code) falls back to a digit-only
445
- * key so at least same-format duplicates still collapse. Hosts wanting global
446
- * dedup pass already-E.164 numbers.
447
- */
448
- declare function normalizePhone(raw: string): string;
449
- /** The stable dedup key for a contact point (`email:ada@x.com`, `phone:+8801...`). */
450
- declare function contactPointKey(type: ContactPointType, value: string): string;
451
- /** A tax-identity dedup key (`vat:...` / `bin:...`) — cross-scope-stable statutory id. */
452
- declare function taxIdentityKey(kind: 'vat' | 'bin', value: string): string;
453
- //#endregion
454
755
  //#region src/domain/errors.d.ts
455
756
  /** Base class so hosts can `instanceof PartyError` across every party throwable. */
456
757
  declare class PartyError extends Error implements HttpError {
@@ -470,6 +771,10 @@ declare class PartyNotFoundError extends PartyError {
470
771
  declare class PartyMergeError extends PartyError {
471
772
  constructor(message: string, sourceId: string, targetId: string);
472
773
  }
774
+ /** A relationship that cannot be recorded or ended as asked (overlap, bad dates, unknown row). */
775
+ declare class PartyRelationshipError extends PartyError {
776
+ constructor(message: string, partyId: string);
777
+ }
473
778
  /**
474
779
  * `resolveOrCreate` lost the create race and the winning row could not be
475
780
  * re-read (should be transient — surfaced rather than swallowed).
@@ -484,18 +789,28 @@ declare class PartyResolveConflictError extends PartyError {
484
789
  declare class PartyVersionConflictError extends PartyError {
485
790
  constructor(id: string, expected: number);
486
791
  }
487
- /**
488
- * A Mongoose model name this package registers is already on the connection.
489
- * Default init throws this; `forceRecreate: true` opts into delete+re-register
490
- * (test fixtures / hot reload only). PACKAGE_RULES §21.
491
- */
492
- declare class PartyModelCollisionError extends PartyError {
493
- constructor(modelName: string);
494
- }
495
792
  /** Input failed a domain-verb precondition (before any DB write). */
496
793
  declare class PartyValidationError extends PartyError {
497
794
  constructor(message: string, meta?: Record<string, unknown>);
498
795
  }
796
+ /**
797
+ * A phone contact point could not be normalized to E.164 — so it cannot become
798
+ * an identity key.
799
+ *
800
+ * This REFUSES rather than degrades, and that is the whole point. The previous
801
+ * behaviour fell back to `+${digits}`, which produced keys like
802
+ * `phone:+01309000993` — neither a national number nor E.164. Two spellings of
803
+ * one member therefore keyed differently and `resolveOrCreate` forked the
804
+ * identity, silently: every screen looked correct and the membership history
805
+ * simply split in two (FAIL LOUD rule 2 — an input we do not understand must
806
+ * fail, not widen).
807
+ *
808
+ * `meta` names the offending value and the region it was interpreted in, so an
809
+ * operator can fix the data instead of guessing which field a 400 refers to.
810
+ */
811
+ declare class PartyPhoneFormatError extends PartyError {
812
+ constructor(value: string, defaultRegion: string | undefined, detail: string);
813
+ }
499
814
  //#endregion
500
815
  //#region src/events/event-constants.d.ts
501
816
  /** PACKAGE_RULES §12: `package:resource.verb`. Emit ONLY these catalog names. */
@@ -505,10 +820,12 @@ declare const PARTY_EVENTS: {
505
820
  readonly ROLE_ADDED: 'party:party.role_added';
506
821
  readonly ROLE_REMOVED: 'party:party.role_removed';
507
822
  readonly USER_LINKED: 'party:party.user_linked';
823
+ readonly CONSENT_RECORDED: 'party:party.consent_recorded';
508
824
  readonly EXTERNAL_REF_ATTACHED: 'party:party.external_ref_attached';
509
825
  readonly ARCHIVED: 'party:party.archived';
510
826
  readonly RESTORED: 'party:party.restored';
511
827
  readonly MERGED: 'party:party.merged';
828
+ readonly RELATIONSHIP_CHANGED: 'party:party.relationship_changed';
512
829
  };
513
830
  type PartyEventName = (typeof PARTY_EVENTS)[keyof typeof PARTY_EVENTS];
514
831
  //#endregion
@@ -533,6 +850,14 @@ interface PartyUserLinkedPayload {
533
850
  partyId: string;
534
851
  userId: string;
535
852
  }
853
+ interface PartyConsentRecordedPayload {
854
+ partyId: string;
855
+ channel: 'email' | 'phone';
856
+ /** The NORMALIZED channel — what a sender would actually dial or mail. */
857
+ value: string;
858
+ status: 'granted' | 'withdrawn';
859
+ source?: string;
860
+ }
536
861
  interface PartyExternalRefAttachedPayload {
537
862
  partyId: string;
538
863
  sourceModel: string;
@@ -556,6 +881,7 @@ interface PartyEventPayloads {
556
881
  [PARTY_EVENTS.ROLE_ADDED]: PartyRoleChangedPayload;
557
882
  [PARTY_EVENTS.ROLE_REMOVED]: PartyRoleChangedPayload;
558
883
  [PARTY_EVENTS.USER_LINKED]: PartyUserLinkedPayload;
884
+ [PARTY_EVENTS.CONSENT_RECORDED]: PartyConsentRecordedPayload;
559
885
  [PARTY_EVENTS.EXTERNAL_REF_ATTACHED]: PartyExternalRefAttachedPayload;
560
886
  [PARTY_EVENTS.ARCHIVED]: PartyLifecyclePayload;
561
887
  [PARTY_EVENTS.RESTORED]: PartyLifecyclePayload;
@@ -577,16 +903,10 @@ declare const partyEventDefinitions: ReadonlyArray<PartyEventDefinition>;
577
903
  declare function createEvent<T>(type: string, payload: T, ctx?: PartyContext, meta?: Partial<EventMeta>): DomainEvent<T>;
578
904
  //#endregion
579
905
  //#region src/events/in-process-bus.d.ts
580
- declare class InProcessPartyBus implements EventTransport {
581
- readonly name = "in-process-party";
582
- private subs;
583
- private readonly logger;
906
+ declare class InProcessPartyBus extends InProcessEventBus {
584
907
  constructor(options?: {
585
- logger?: PartyLogger | undefined;
908
+ logger?: PartyLogger;
586
909
  });
587
- publish(event: DomainEvent): Promise<void>;
588
- subscribe(pattern: string, handler: EventHandler): Promise<() => void>;
589
- close(): Promise<void>;
590
910
  }
591
911
  //#endregion
592
- export { ADDRESS_TYPES, type AddressType, CONTACT_POINT_TYPES, type ContactPoint, type ContactPointType, type ExternalRef, type IPartyDocument, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS, PARTY_MODEL_NAMES, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PENDING_EVENTS, type PartyAddress, type PartyConfig, type PartyContext, type PartyEngine, PartyError, PartyEventDispatcher, type PartyEventName, type PartyEventPayloads, type PartyKind, type PartyLogger, PartyMergeError, PartyModelCollisionError, type PartyModels, PartyNotFoundError, type PartyPort, type PartyRepositories, PartyRepository, type PartyRepositoryDeps, PartyResolveConflictError, type PartyRole, type PartySnapshot, type PartyStatus, PartyValidationError, PartyVersionConflictError, type PersonName, type ResolvePartyInput, type ResolveResult, type ResolvedPartyConfig, type SchemaBuildConfig, type TaxIdentity, type TenantFieldType, buildContactPoints, buildPartySchema, contactPointKey, createEvent, createParty, createParty as default, createPartyModels, dedupe, deriveIdentityKeys, flushPending, normalizeEmail, normalizePhone, partyEventDefinitions, pickPrimary, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };
912
+ export { ADDRESS_TYPES, type AddRelationshipInput, type AddressType, CONSENT_STATUSES, CONTACT_POINT_TYPES, type ChannelConsent, type ConsentStatus, type ContactPoint, type ContactPointType, type ExternalRef, type IPartyDocument, type IdentityKeyOptions, InProcessPartyBus, type NormalizeOptions, PARTY_EVENTS, PARTY_KINDS, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PARTY_SUBJECT_MODEL, PENDING_EVENTS, type PartyAddress, type PartyBlueprint, type PartyContext, type PartyEngine, PartyError, PartyEventDispatcher, type PartyEventName, type PartyEventPayloads, type PartyKind, type PartyLogger, PartyMergeError, type PartyModels, PartyNotFoundError, PartyPhoneFormatError, type PartyPort, type PartyRelationship, PartyRelationshipError, type PartyRepositories, PartyRepository, type PartyRepositoryDeps, PartyResolveConflictError, type PartyRole, type PartyRuntime, type PartyShape, type PartySnapshot, type PartyStatus, type PartySubject, PartyValidationError, PartyVersionConflictError, type PersonName, RELATIONSHIP_KINDS, type RecordConsentInput, type RelationshipKind, type ResolvePartyInput, type ResolveResult, type ResolvedPartyConfig, type SchemaBuildConfig, type TaxIdentity, type TenantFieldType, buildContactPoints, buildPartySchema, contactPointKey, createEvent, dedupe, defineParty, definePartyModels, deriveIdentityKeys, flushPending, isPartySubject, normalizeEmail, partyEventDefinitions, partySubject, pickPrimary, reconcileConsent, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };