@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.js CHANGED
@@ -1,80 +1,14 @@
1
- import { Repository, isDuplicateKeyError, multiTenantPlugin, withTransaction } from "@classytic/mongokit";
2
- import { Schema } from "mongoose";
1
+ import { Repository, defineModels, isDuplicateKeyError, multiTenantPlugin, transactionResolutionOf, withTransaction } from "@classytic/mongokit";
2
+ import { assertNoLegacyTenantKeys, resolveTenantConfig } from "@classytic/repo-core/tenant";
3
+ import { InProcessEventBus } from "@classytic/primitives/event-infra";
4
+ import { LEGAL_FORMS } from "@classytic/primitives/legal-form";
5
+ import { Schema, Types } from "mongoose";
3
6
  import { defineStateMachine } from "@classytic/primitives/state-machine";
4
- import { parsePhone } from "@classytic/primitives/phone";
5
- import { isOk } from "@classytic/primitives/result";
6
- import { createEvent as createEvent$1, matchEventPattern } from "@classytic/primitives/events";
7
+ import { normalizeEmail as normalizeEmail$1 } from "@classytic/contact/email";
8
+ import { PhoneFormatError, normalizePhone } from "@classytic/contact/phone";
9
+ import { createEvent as createEvent$1 } from "@classytic/primitives/events";
10
+ import { idToString } from "@classytic/primitives/reference";
7
11
  import { z } from "zod";
8
- //#region src/domain/errors.ts
9
- /** Base class so hosts can `instanceof PartyError` across every party throwable. */
10
- var PartyError = class extends Error {
11
- status;
12
- code;
13
- meta;
14
- constructor(status, code, message, meta) {
15
- super(message);
16
- this.name = new.target.name;
17
- this.status = status;
18
- this.code = code;
19
- if (meta !== void 0) this.meta = meta;
20
- }
21
- };
22
- /** A referenced party id does not exist (or is out of the caller's scope). */
23
- var PartyNotFoundError = class extends PartyError {
24
- constructor(id) {
25
- super(404, "party.not_found", `Party "${id}" not found.`, { partyId: id });
26
- }
27
- };
28
- /**
29
- * A merge was attempted against an illegal target — self-merge, merging into an
30
- * already-merged (redirected) party, or a cross-scope pair. Carries both ids.
31
- */
32
- var PartyMergeError = class extends PartyError {
33
- constructor(message, sourceId, targetId) {
34
- super(409, "party.merge_conflict", message, {
35
- sourceId,
36
- targetId
37
- });
38
- }
39
- };
40
- /**
41
- * `resolveOrCreate` lost the create race and the winning row could not be
42
- * re-read (should be transient — surfaced rather than swallowed).
43
- */
44
- var PartyResolveConflictError = class extends PartyError {
45
- constructor(identityKey) {
46
- super(409, "party.resolve_conflict", `Concurrent create for identity "${identityKey}" could not be reconciled.`, { identityKey });
47
- }
48
- };
49
- /**
50
- * Optimistic-concurrency guard failed — the party was modified by another
51
- * writer between read and write. Callers re-fetch and retry.
52
- */
53
- var PartyVersionConflictError = class extends PartyError {
54
- constructor(id, expected) {
55
- super(409, "party.version_conflict", `Party "${id}" was modified concurrently (expected v${expected}).`, {
56
- partyId: id,
57
- expectedVersion: expected
58
- });
59
- }
60
- };
61
- /**
62
- * A Mongoose model name this package registers is already on the connection.
63
- * Default init throws this; `forceRecreate: true` opts into delete+re-register
64
- * (test fixtures / hot reload only). PACKAGE_RULES §21.
65
- */
66
- var PartyModelCollisionError = class extends PartyError {
67
- constructor(modelName) {
68
- super(500, "party.model_collision", `Mongoose model "${modelName}" is already registered on this connection. Another @classytic/party engine or a stale hot-reload owns it. Pass forceRecreate: true (tests / hot reload only) or use a dedicated connection.`, { modelName });
69
- }
70
- };
71
- /** Input failed a domain-verb precondition (before any DB write). */
72
- var PartyValidationError = class extends PartyError {
73
- constructor(message, meta) {
74
- super(400, "party.validation", message, meta);
75
- }
76
- };
77
- //#endregion
78
12
  //#region src/domain/enums.ts
79
13
  /**
80
14
  * Party-native enumerations.
@@ -116,6 +50,17 @@ const PARTY_STATUSES = [
116
50
  /** Channel kind for a contact point. Drives normalization + dedup keys. */
117
51
  const CONTACT_POINT_TYPES = ["email", "phone"];
118
52
  /**
53
+ * Whether this channel may be used for MARKETING. Transactional messages (your
54
+ * booking is confirmed, your invoice is due) need no consent and are not what
55
+ * this records.
56
+ *
57
+ * Two states only, and `withdrawn` is deliberately not "absent": a channel that
58
+ * was never asked and a channel someone opted OUT of are the same silence to a
59
+ * sender but not to a regulator, and only the second one must survive a merge,
60
+ * a re-import, or the person booking again.
61
+ */
62
+ const CONSENT_STATUSES = ["granted", "withdrawn"];
63
+ /**
119
64
  * Address role. Mirrors Odoo's `type` (contact/invoice/delivery/other) but
120
65
  * as a first-class enum. A party carries many; `isDefault` picks the fallback.
121
66
  */
@@ -125,6 +70,107 @@ const ADDRESS_TYPES = [
125
70
  "contact",
126
71
  "other"
127
72
  ];
73
+ /**
74
+ * How a party is related to the reporting company (or to another party) — the related-party facts tax and
75
+ * disclosure rules read (IAS 24 in spirit). One party can hold several at once (a director who is also a
76
+ * shareholder); each is dated. A pack derives its own terms from these (a "sponsor shareholder").
77
+ */
78
+ const RELATIONSHIP_KINDS = [
79
+ "shareholder",
80
+ "director",
81
+ "key_management",
82
+ "partner_in_firm",
83
+ "member",
84
+ "employee",
85
+ "lessor",
86
+ "associate"
87
+ ];
88
+ //#endregion
89
+ //#region src/events/dispatch.ts
90
+ /** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
91
+ const PENDING_EVENTS = Symbol.for("@classytic/party:pendingEvents");
92
+ var PartyEventDispatcher = class {
93
+ name;
94
+ transport;
95
+ outbox;
96
+ logger;
97
+ constructor(opts) {
98
+ this.transport = opts.transport;
99
+ this.outbox = opts.outbox;
100
+ this.logger = opts.logger;
101
+ this.name = opts.transport.name;
102
+ }
103
+ /** Raw publish — hosts and the unit-of-work flush use this. */
104
+ publish(event) {
105
+ return this.transport.publish(event);
106
+ }
107
+ subscribe(pattern, handler) {
108
+ const subscribe = this.transport.subscribe?.bind(this.transport);
109
+ if (!subscribe) throw new Error("party: configured event transport does not support subscribe");
110
+ return subscribe(pattern, handler);
111
+ }
112
+ /**
113
+ * Route a domain event with full §P8 discipline. Reads the pending queue and
114
+ * session off `ctx`.
115
+ */
116
+ async dispatch(event, ctx) {
117
+ if (this.outbox) try {
118
+ await this.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
119
+ } catch (err) {
120
+ this.logger?.error?.(`[party] outbox.save failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
121
+ throw err;
122
+ }
123
+ const queue = ctx[PENDING_EVENTS];
124
+ if (Array.isArray(queue)) {
125
+ queue.push(event);
126
+ return;
127
+ }
128
+ try {
129
+ await this.transport.publish(event);
130
+ } catch (err) {
131
+ this.logger?.error?.(`[party] publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
132
+ }
133
+ }
134
+ async close() {
135
+ await this.transport.close?.();
136
+ }
137
+ };
138
+ /**
139
+ * Drain a pending-events queue to the transport AFTER the owning transaction
140
+ * commits. Per-event failures are swallowed — durable delivery is the outbox's
141
+ * job (PACKAGE_RULES §P8.1).
142
+ */
143
+ async function flushPending(dispatcher, queue, logger) {
144
+ for (const event of queue) try {
145
+ await dispatcher.publish(event);
146
+ } catch (err) {
147
+ logger?.error?.(`[party] post-commit publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
148
+ }
149
+ }
150
+ //#endregion
151
+ //#region src/events/in-process-bus.ts
152
+ /**
153
+ * In-process event bus — default fallback when no `eventTransport` is given to
154
+ * `createParty`.
155
+ *
156
+ * A thin subclass of `@classytic/primitives/event-infra`'s shared
157
+ * `InProcessEventBus`. Prior semantics preserved: glob matching via
158
+ * `matchEventPattern`, per-handler error isolation, idempotent `close()`.
159
+ * Party historically SWALLOWED handler errors when no logger was supplied —
160
+ * that default is preserved by passing `logger: null` unless the host injects
161
+ * a `PartyLogger`, whose structured `error(msg, meta)` slot is bridged onto
162
+ * the shared error-logger contract.
163
+ */
164
+ var InProcessPartyBus = class extends InProcessEventBus {
165
+ constructor(options) {
166
+ const partyLogger = options?.logger;
167
+ super({
168
+ name: "in-process-party",
169
+ logLabel: "party",
170
+ logger: partyLogger?.error ? { error: (message, ...args) => partyLogger.error?.(message, { args }) } : null
171
+ });
172
+ }
173
+ };
128
174
  //#endregion
129
175
  //#region src/models/tenant-field.ts
130
176
  /**
@@ -144,15 +190,15 @@ const ADDRESS_TYPES = [
144
190
  * company-wide (`multiTenant: false`) so the field is simply not added.
145
191
  */
146
192
  function tenantFieldDef(config) {
147
- if (!config.multiTenant) return void 0;
148
- return config.tenantFieldType === "objectId" ? {
193
+ if (!config.tenant.enabled) return void 0;
194
+ return config.tenant.fieldType === "objectId" ? {
149
195
  type: Schema.Types.ObjectId,
150
- required: true,
151
- ref: "organization",
196
+ required: config.tenant.required,
197
+ ref: config.tenant.ref,
152
198
  index: true
153
199
  } : {
154
200
  type: String,
155
- required: true,
201
+ required: config.tenant.required,
156
202
  index: true
157
203
  };
158
204
  }
@@ -202,6 +248,27 @@ const personNameSchema = new Schema({
202
248
  maxlength: 120
203
249
  }
204
250
  }, { _id: false });
251
+ const channelConsentSchema = new Schema({
252
+ status: {
253
+ type: String,
254
+ enum: CONSENT_STATUSES,
255
+ required: true
256
+ },
257
+ at: {
258
+ type: Date,
259
+ required: true
260
+ },
261
+ source: {
262
+ type: String,
263
+ trim: true,
264
+ maxlength: 120
265
+ },
266
+ ip: {
267
+ type: String,
268
+ trim: true,
269
+ maxlength: 64
270
+ }
271
+ }, { _id: false });
205
272
  const contactPointSchema = new Schema({
206
273
  type: {
207
274
  type: String,
@@ -218,6 +285,11 @@ const contactPointSchema = new Schema({
218
285
  type: String,
219
286
  required: true
220
287
  },
288
+ input: {
289
+ type: String,
290
+ trim: true,
291
+ maxlength: 320
292
+ },
221
293
  isPrimary: {
222
294
  type: Boolean,
223
295
  default: false
@@ -225,7 +297,8 @@ const contactPointSchema = new Schema({
225
297
  verified: {
226
298
  type: Boolean,
227
299
  default: false
228
- }
300
+ },
301
+ consent: { type: channelConsentSchema }
229
302
  }, { _id: false });
230
303
  const addressSchema = new Schema({
231
304
  type: {
@@ -314,6 +387,44 @@ const taxIdentitySchema = new Schema({
314
387
  maxlength: 2
315
388
  }
316
389
  }, { _id: false });
390
+ const relationshipSchema = new Schema({
391
+ kind: {
392
+ type: String,
393
+ enum: RELATIONSHIP_KINDS,
394
+ required: true
395
+ },
396
+ relatedTo: {
397
+ type: Schema.Types.ObjectId,
398
+ ref: "Party",
399
+ default: null
400
+ },
401
+ shareClass: {
402
+ type: String,
403
+ trim: true,
404
+ maxlength: 60,
405
+ default: null
406
+ },
407
+ sharePercent: {
408
+ type: Number,
409
+ min: 0,
410
+ max: 100,
411
+ default: null
412
+ },
413
+ from: {
414
+ type: Date,
415
+ required: true
416
+ },
417
+ to: {
418
+ type: Date,
419
+ default: null
420
+ },
421
+ note: {
422
+ type: String,
423
+ trim: true,
424
+ maxlength: 300,
425
+ default: null
426
+ }
427
+ });
317
428
  const externalRefSchema$1 = new Schema({
318
429
  sourceId: {
319
430
  type: String,
@@ -327,7 +438,7 @@ const externalRefSchema$1 = new Schema({
327
438
  function buildPartySchema(config) {
328
439
  const tenantField = tenantFieldDef(config);
329
440
  const schema = new Schema({
330
- ...tenantField ? { organizationId: tenantField } : {},
441
+ ...tenantField ? { [config.tenant.tenantField]: tenantField } : {},
331
442
  kind: {
332
443
  type: String,
333
444
  enum: PARTY_KINDS,
@@ -390,6 +501,14 @@ function buildPartySchema(config) {
390
501
  type: [String],
391
502
  default: []
392
503
  },
504
+ legalForm: {
505
+ type: String,
506
+ enum: LEGAL_FORMS
507
+ },
508
+ relationships: {
509
+ type: [relationshipSchema],
510
+ default: []
511
+ },
393
512
  metadata: { type: Schema.Types.Mixed },
394
513
  version: {
395
514
  type: Number,
@@ -400,11 +519,14 @@ function buildPartySchema(config) {
400
519
  toJSON: { virtuals: true },
401
520
  toObject: { virtuals: true }
402
521
  });
403
- const scoped = (fields) => config.multiTenant ? {
404
- organizationId: 1,
522
+ const scoped = (fields) => config.tenant.enabled ? {
523
+ [config.tenant.tenantField]: 1,
405
524
  ...fields
406
525
  } : fields;
407
- schema.index(scoped({ identityKeys: 1 }), { unique: true });
526
+ schema.index(scoped({ identityKeys: 1 }), {
527
+ unique: true,
528
+ partialFilterExpression: { "identityKeys.0": { $exists: true } }
529
+ });
408
530
  schema.index(scoped({
409
531
  roles: 1,
410
532
  status: 1
@@ -412,6 +534,10 @@ function buildPartySchema(config) {
412
534
  schema.index(scoped({ createdAt: -1 }));
413
535
  schema.index(scoped({ parentId: 1 }));
414
536
  schema.index(scoped({ userLinks: 1 }));
537
+ schema.index(scoped({
538
+ "relationships.kind": 1,
539
+ "relationships.relatedTo": 1
540
+ }));
415
541
  schema.index({
416
542
  "externalRefs.sourceModel": 1,
417
543
  "externalRefs.sourceId": 1
@@ -428,21 +554,127 @@ function buildPartySchema(config) {
428
554
  }
429
555
  //#endregion
430
556
  //#region src/models/create-models.ts
431
- /** Mongoose model names this package registers — drives the §21 collision check. */
432
- const PARTY_MODEL_NAMES = ["Party"];
433
- function createPartyModels(connection, config) {
434
- const forceRecreate = config.forceRecreate ?? false;
557
+ /**
558
+ * Party model specification — the ONE authoritative place the Party model is described.
559
+ *
560
+ * `definePartyModels(config)` returns a connection-free mongokit `ModelBlueprint`; `defineParty`
561
+ * binds it during engine construction. Collision handling, auto-index and (future) collection
562
+ * config live here once (PACKAGE_RULES §20/§21). A name collision surfaces as mongokit's typed
563
+ * `ModelCollisionError`.
564
+ */
565
+ /**
566
+ * Describe the Party model set as a connection-free blueprint. Pure — builds no schema and
567
+ * registers nothing until `.bind(connection)`.
568
+ */
569
+ function definePartyModels(config) {
435
570
  const autoIndex = config.autoIndex ?? true;
436
- if (forceRecreate) {
437
- for (const name of PARTY_MODEL_NAMES) if (connection.models[name]) connection.deleteModel(name);
438
- } else for (const name of PARTY_MODEL_NAMES) if (connection.models[name]) throw new PartyModelCollisionError(name);
439
- const register = (name, schema) => {
440
- schema.set("autoIndex", autoIndex);
441
- return connection.model(name, schema);
442
- };
443
- return Object.freeze({ Party: register("Party", buildPartySchema(config)) });
571
+ return defineModels({
572
+ models: [{
573
+ name: "Party",
574
+ schema: () => {
575
+ const schema = buildPartySchema(config);
576
+ schema.set("autoIndex", autoIndex);
577
+ return schema;
578
+ },
579
+ existing: config.forceRecreate ? {
580
+ mode: "replace",
581
+ environment: "test"
582
+ } : { mode: "throw" }
583
+ }],
584
+ assemble: (models) => Object.freeze({ Party: models.get("Party") })
585
+ });
444
586
  }
445
587
  //#endregion
588
+ //#region src/domain/errors.ts
589
+ /** Base class so hosts can `instanceof PartyError` across every party throwable. */
590
+ var PartyError = class extends Error {
591
+ status;
592
+ code;
593
+ meta;
594
+ constructor(status, code, message, meta) {
595
+ super(message);
596
+ this.name = new.target.name;
597
+ this.status = status;
598
+ this.code = code;
599
+ if (meta !== void 0) this.meta = meta;
600
+ }
601
+ };
602
+ /** A referenced party id does not exist (or is out of the caller's scope). */
603
+ var PartyNotFoundError = class extends PartyError {
604
+ constructor(id) {
605
+ super(404, "party.not_found", `Party "${id}" not found.`, { partyId: id });
606
+ }
607
+ };
608
+ /**
609
+ * A merge was attempted against an illegal target — self-merge, merging into an
610
+ * already-merged (redirected) party, or a cross-scope pair. Carries both ids.
611
+ */
612
+ var PartyMergeError = class extends PartyError {
613
+ constructor(message, sourceId, targetId) {
614
+ super(409, "party.merge_conflict", message, {
615
+ sourceId,
616
+ targetId
617
+ });
618
+ }
619
+ };
620
+ /** A relationship that cannot be recorded or ended as asked (overlap, bad dates, unknown row). */
621
+ var PartyRelationshipError = class extends PartyError {
622
+ constructor(message, partyId) {
623
+ super(422, "party.relationship_invalid", message, { partyId });
624
+ }
625
+ };
626
+ /**
627
+ * `resolveOrCreate` lost the create race and the winning row could not be
628
+ * re-read (should be transient — surfaced rather than swallowed).
629
+ */
630
+ var PartyResolveConflictError = class extends PartyError {
631
+ constructor(identityKey) {
632
+ super(409, "party.resolve_conflict", `Concurrent create for identity "${identityKey}" could not be reconciled.`, { identityKey });
633
+ }
634
+ };
635
+ /**
636
+ * Optimistic-concurrency guard failed — the party was modified by another
637
+ * writer between read and write. Callers re-fetch and retry.
638
+ */
639
+ var PartyVersionConflictError = class extends PartyError {
640
+ constructor(id, expected) {
641
+ super(409, "party.version_conflict", `Party "${id}" was modified concurrently (expected v${expected}).`, {
642
+ partyId: id,
643
+ expectedVersion: expected
644
+ });
645
+ }
646
+ };
647
+ /** Input failed a domain-verb precondition (before any DB write). */
648
+ var PartyValidationError = class extends PartyError {
649
+ constructor(message, meta) {
650
+ super(400, "party.validation", message, meta);
651
+ }
652
+ };
653
+ /**
654
+ * A phone contact point could not be normalized to E.164 — so it cannot become
655
+ * an identity key.
656
+ *
657
+ * This REFUSES rather than degrades, and that is the whole point. The previous
658
+ * behaviour fell back to `+${digits}`, which produced keys like
659
+ * `phone:+01309000993` — neither a national number nor E.164. Two spellings of
660
+ * one member therefore keyed differently and `resolveOrCreate` forked the
661
+ * identity, silently: every screen looked correct and the membership history
662
+ * simply split in two (FAIL LOUD rule 2 — an input we do not understand must
663
+ * fail, not widen).
664
+ *
665
+ * `meta` names the offending value and the region it was interpreted in, so an
666
+ * operator can fix the data instead of guessing which field a 400 refers to.
667
+ */
668
+ var PartyPhoneFormatError = class extends PartyError {
669
+ constructor(value, defaultRegion, detail) {
670
+ super(400, "party.phone_invalid", `Phone ${JSON.stringify(value)} is not a valid ${defaultRegion ?? "E.164"} number (${detail}). Identity requires an E.164 phone; supply a dialable number or configure the deployment region (\`defaultPhoneRegion\`) so national formats can be interpreted. It is NOT stored as typed — a raw spelling used as an identity key is how one person becomes several parties.`, {
671
+ value,
672
+ ...defaultRegion !== void 0 ? { defaultRegion } : {},
673
+ detail
674
+ });
675
+ }
676
+ };
677
+ //#endregion
446
678
  //#region src/domain/state-machine.ts
447
679
  /**
448
680
  * Party identity lifecycle FSM — the compile-time twin of the `repo.claim()`
@@ -471,32 +703,89 @@ const PARTY_STATE_MACHINE = defineStateMachine({
471
703
  * SAME key regardless of casing/spacing/punctuation, so `resolveOrCreate` finds
472
704
  * the existing party instead of forking a duplicate.
473
705
  *
474
- * Phone normalization DELEGATES to `@classytic/primitives/phone` (the canonical
475
- * E.164 parser) — we never re-implement phone parsing (PACKAGE_RULES §P12).
476
- * Email normalization is a conservative trim+lowercase: aggressive canonical-
477
- * ization (gmail dot/plus stripping) is WRONG for identity — `a+shop@x.com` is
478
- * a real, distinct inbox.
706
+ * ## Both rules live in `@classytic/contact`, imported DIRECTLY
707
+ *
708
+ * Neither implementation is here. Both are in `@classytic/contact`, a kernel
709
+ * below this one, and this file is a CALLER of it — not a re-export of it. That
710
+ * distinction is the whole point: the phone rule had three implementations (here,
711
+ * be-prod's `shared/phone.ts`, and hotel's), and a re-export would have been a
712
+ * fourth NAME for one rule, which is how the drift started. A consumer that wants
713
+ * to normalize a contact point imports `@classytic/contact/phone` or
714
+ * `@classytic/contact/email`; it does not reach through party.
715
+ *
716
+ * `@classytic/primitives/phone` deliberately does not do the phone work — it is a
717
+ * zero-dependency E.164 value object whose own docblock says *"deeper
718
+ * validation … is the host's call to layer on top"*. `@classytic/contact` IS that
719
+ * layer, and `ParsedPhone` extends the primitive rather than re-declaring it.
720
+ *
721
+ * ## What this file still owns: the WIRE contract
722
+ *
723
+ * `@classytic/contact` throws plain `Error` subclasses — it sits below every
724
+ * domain kernel and knows nothing about HTTP. Party owns a 400 /
725
+ * `party.phone_invalid` contract that arc serializes natively, so the boundary
726
+ * translation happens here, once, in the one function that derives the key.
727
+ *
728
+ * `PhoneRegionError` is deliberately NOT translated: a misconfigured
729
+ * `defaultPhoneRegion` is a deployment fault, not a customer's bad phone number,
730
+ * and turning it into a 400 on `customer.phone` would blame the wrong party (in
731
+ * both senses) for every number in the country.
732
+ *
733
+ * ## `@classytic/contact` is a PEER dependency, and that is load-bearing
734
+ *
735
+ * Not a `dependencies` entry. npm may nest a regular dependency under
736
+ * `party/node_modules/`, which would give the host a SECOND module instance —
737
+ * and then `err instanceof PhoneFormatError` is `false` at a host catch site
738
+ * that imported its own copy. The guard would compile, run, and match nothing.
739
+ * A peer forces exactly one copy per host, so class identity holds across the
740
+ * boundary.
741
+ *
742
+ * ## The region is CONFIGURATION, never a constant
743
+ *
744
+ * `defaultRegion` is an ISO 3166-1 alpha-2 code supplied by the deployment
745
+ * (`PartyShape.defaultPhoneRegion` → `deployment.config.ts`). This kernel names
746
+ * no country. With no region configured, only explicit E.164 input is accepted:
747
+ * refusing is correct, because guessing a country is how you mint a plausible
748
+ * wrong number.
749
+ */
750
+ /**
751
+ * Canonical email for the dedup key. Trim + lowercase, and deliberately NOT
752
+ * gmail dot / `+tag` folding — `a+shop@x.com` is a real, distinct inbox, and
753
+ * folding it MERGES two people, which is the irreversible direction.
754
+ *
755
+ * The rule moved to `@classytic/contact/email` unchanged for the canonical case,
756
+ * with one addition: an address that cannot be canonicalised now THROWS instead of
757
+ * becoming an identity key. Same reasoning as the phone side — a malformed address
758
+ * still makes a perfectly stable key, so admitting it does not fail, it forks.
479
759
  */
480
- /** Lowercase + trim an email. Conservative — never strips dots/plus tags. */
481
760
  function normalizeEmail(raw) {
482
- return raw.trim().toLowerCase();
761
+ return normalizeEmail$1(raw);
483
762
  }
484
763
  /**
485
- * Canonical phone form for dedup. Uses primitives' `parsePhone`: a valid E.164
486
- * input collapses to its canonical `+<cc><national>`; anything the strict parser
487
- * rejects (local formats without a country code) falls back to a digit-only
488
- * key so at least same-format duplicates still collapse. Hosts wanting global
489
- * dedup pass already-E.164 numbers.
764
+ * Canonical E.164 form of a phone number, for dedup AND for storage.
765
+ *
766
+ * Delegates to `@classytic/contact`, then re-throws its refusal as party's own
767
+ * `PartyPhoneFormatError` (400 / `party.phone_invalid`) so arc serializes it
768
+ * natively. There is no fallback at either layer: the old `+${digits}`
769
+ * degradation is precisely what split one member into several parties, and it
770
+ * did so without erroring.
771
+ *
772
+ * NOT exported from this kernel's public surface. A consumer that wants to
773
+ * normalize a phone imports `@classytic/contact/phone` directly — one rule,
774
+ * one name. What party owns is `buildContactPoints`, the single point every
775
+ * identity write crosses.
490
776
  */
491
- function normalizePhone(raw) {
492
- const parsed = parsePhone(raw.trim());
493
- if (isOk(parsed)) return parsed.value.e164;
494
- const digits = raw.replace(/\D/g, "");
495
- return digits ? `+${digits}` : "";
777
+ function normalizePhone$1(raw, options = {}) {
778
+ const defaultRegion = options.defaultRegion;
779
+ try {
780
+ return normalizePhone(raw, { ...defaultRegion !== void 0 ? { defaultRegion } : {} });
781
+ } catch (err) {
782
+ if (err instanceof PhoneFormatError) throw new PartyPhoneFormatError(err.input, defaultRegion, err.detail ?? err.code);
783
+ throw err;
784
+ }
496
785
  }
497
786
  /** The stable dedup key for a contact point (`email:ada@x.com`, `phone:+8801...`). */
498
- function contactPointKey(type, value) {
499
- return `${type}:${type === "email" ? normalizeEmail(value) : normalizePhone(value)}`;
787
+ function contactPointKey(type, value, options = {}) {
788
+ return `${type}:${type === "email" ? normalizeEmail(value) : normalizePhone$1(value, options)}`;
500
789
  }
501
790
  /** A tax-identity dedup key (`vat:...` / `bin:...`) — cross-scope-stable statutory id. */
502
791
  function taxIdentityKey(kind, value) {
@@ -517,28 +806,39 @@ function dedupe(arr) {
517
806
  /**
518
807
  * Turn the input's `emails[]` / `phones[]` into normalized `ContactPoint`s. The
519
808
  * first email and first phone are marked primary; blanks are skipped.
809
+ *
810
+ * THE SEAM. Both `resolveOrCreate` and `syncIdentity` funnel here, so this is
811
+ * the one place normalization can be enforced — a fix applied at N call sites
812
+ * is the same bug waiting at N+1.
813
+ *
814
+ * Two spellings of one number in a single input collapse to ONE point (they
815
+ * share a key). An unparseable phone THROWS rather than being skipped: skipping
816
+ * would create a party with no phone identity at all, which the next
817
+ * well-formed visit would not resolve onto — a silent fork by a different route.
520
818
  */
521
- function buildContactPoints(input) {
819
+ function buildContactPoints(input, options = {}) {
522
820
  const points = [];
523
- (input.emails ?? []).forEach((value, i) => {
524
- if (!value.trim()) return;
821
+ const seen = /* @__PURE__ */ new Set();
822
+ const push = (type, raw, canonical, isFirst) => {
823
+ const key = `${type}:${canonical}`;
824
+ if (seen.has(key)) return;
825
+ seen.add(key);
525
826
  points.push({
526
- type: "email",
527
- value,
528
- key: contactPointKey("email", value),
529
- isPrimary: i === 0,
827
+ type,
828
+ value: canonical,
829
+ key,
830
+ ...raw.trim() !== canonical ? { input: raw.trim() } : {},
831
+ isPrimary: isFirst,
530
832
  verified: false
531
833
  });
834
+ };
835
+ (input.emails ?? []).forEach((value, i) => {
836
+ if (!value.trim()) return;
837
+ push("email", value, normalizeEmail(value), i === 0);
532
838
  });
533
839
  (input.phones ?? []).forEach((value, i) => {
534
840
  if (!value.trim()) return;
535
- points.push({
536
- type: "phone",
537
- value,
538
- key: contactPointKey("phone", value),
539
- isPrimary: i === 0,
540
- verified: false
541
- });
841
+ push("phone", value, normalizePhone$1(value, options), i === 0);
542
842
  });
543
843
  return points;
544
844
  }
@@ -547,8 +847,9 @@ function buildContactPoints(input) {
547
847
  * statutory ids, and external-store refs. Blank/degenerate keys are dropped so
548
848
  * they never poison the unique index.
549
849
  */
550
- function deriveIdentityKeys(contactPoints, input) {
551
- const keys = contactPoints.map((c) => c.key);
850
+ function deriveIdentityKeys(contactPoints, input, options = {}) {
851
+ const contactTypes = options.contactTypes ?? ["email", "phone"];
852
+ const keys = contactPoints.filter((c) => contactTypes.includes(c.type)).map((c) => c.key);
552
853
  if (input.taxIdentity?.vat) keys.push(taxIdentityKey("vat", input.taxIdentity.vat));
553
854
  if (input.taxIdentity?.bin) keys.push(taxIdentityKey("bin", input.taxIdentity.bin));
554
855
  for (const ref of input.externalRefs ?? []) keys.push(`ref:${ref.sourceModel}:${ref.sourceId}`);
@@ -592,22 +893,116 @@ function unionRefs(a, b) {
592
893
  return out;
593
894
  }
594
895
  /**
896
+ * Reconcile two consent records for the SAME channel.
897
+ *
898
+ * A withdrawal always beats a grant, regardless of which is newer. Recency is
899
+ * the intuitive rule and it is the wrong one here: the two records being
900
+ * reconciled came from duplicate rows whose clocks record when each row was
901
+ * TOUCHED, not the order the person actually spoke, so letting the later
902
+ * timestamp win means a merge can quietly re-subscribe someone who opted out.
903
+ * Being over-cautious costs a marketing email; being wrong costs a complaint
904
+ * you cannot defend. Between two records of the same status the later one wins,
905
+ * since only its timestamp is then load-bearing.
906
+ */
907
+ function reconcileConsent(a, b) {
908
+ if (!a) return b;
909
+ if (!b) return a;
910
+ if (a.status !== b.status) return a.status === "withdrawn" ? a : b;
911
+ return b.at > a.at ? b : a;
912
+ }
913
+ /**
595
914
  * Union contact points by their normalized `key`. The survivor keeps its own
596
915
  * primaries; absorbed points come in as non-primary (a merge must never create
597
916
  * two primaries of the same channel).
917
+ *
918
+ * Where both sides hold the same channel the survivor's row is kept but its
919
+ * CONSENT is reconciled — dropping the loser's row wholesale would discard an
920
+ * opt-out that the duplicate was the only carrier of.
598
921
  */
599
922
  function unionContacts(a, b) {
600
- const seen = new Set(a.map((c) => c.key));
923
+ const out = a.map(plainPoint);
924
+ const at = new Map(out.map((c, i) => [c.key, i]));
925
+ for (const c of b) {
926
+ const existing = at.get(c.key);
927
+ if (existing === void 0) {
928
+ at.set(c.key, out.length);
929
+ out.push({
930
+ ...plainPoint(c),
931
+ isPrimary: false
932
+ });
933
+ continue;
934
+ }
935
+ const survivor = out[existing];
936
+ const consent = reconcileConsent(survivor.consent, c.consent);
937
+ out[existing] = {
938
+ ...survivor,
939
+ verified: survivor.verified || c.verified,
940
+ ...consent !== void 0 ? { consent } : {}
941
+ };
942
+ }
943
+ return out;
944
+ }
945
+ /**
946
+ * Copy a contact point FIELD BY FIELD.
947
+ *
948
+ * These arrive as Mongoose subdocuments, whose values live behind prototype
949
+ * getters — spreading one yields its internals (`_doc`, `$__`) and none of its
950
+ * data, and an override written alongside them is then dropped on cast. Reading
951
+ * each property goes through the getter and gets the real value, so this is the
952
+ * only shape that survives being handed back to `$set`.
953
+ */
954
+ function plainPoint(c) {
955
+ return {
956
+ type: c.type,
957
+ value: c.value,
958
+ key: c.key,
959
+ ...c.input !== void 0 ? { input: c.input } : {},
960
+ isPrimary: c.isPrimary,
961
+ verified: c.verified,
962
+ ...c.consent !== void 0 ? { consent: {
963
+ status: c.consent.status,
964
+ at: c.consent.at,
965
+ ...c.consent.source !== void 0 ? { source: c.consent.source } : {},
966
+ ...c.consent.ip !== void 0 ? { ip: c.consent.ip } : {}
967
+ } } : {}
968
+ };
969
+ }
970
+ /**
971
+ * Union postal addresses, deduping on `(type, line1, postalCode)` — the same
972
+ * address arriving from both sides collapses to one. Absorbed addresses keep
973
+ * their own recipient/label but yield `isDefault` to the survivor: a merge must
974
+ * never produce two default addresses (mirrors `unionContacts`' primary rule).
975
+ */
976
+ function unionAddresses(a, b) {
977
+ const key = (ad) => `${ad.type}|${(ad.line1 ?? "").trim().toLowerCase()}|${(ad.postalCode ?? "").trim().toLowerCase()}`;
978
+ const seen = new Set(a.map(key));
601
979
  const out = [...a];
602
- for (const c of b) if (!seen.has(c.key)) {
603
- seen.add(c.key);
604
- out.push({
605
- ...c,
606
- isPrimary: false
607
- });
980
+ let hasDefault = a.some((ad) => ad.isDefault);
981
+ for (const ad of b) {
982
+ const k = key(ad);
983
+ if (seen.has(k)) continue;
984
+ seen.add(k);
985
+ const next = hasDefault && ad.isDefault ? {
986
+ ...ad,
987
+ isDefault: false
988
+ } : ad;
989
+ if (next.isDefault) hasDefault = true;
990
+ out.push(next);
608
991
  }
609
992
  return out;
610
993
  }
994
+ /**
995
+ * Shallow-merge two metadata bags: the survivor (`a`) wins on every key it
996
+ * already holds; the loser (`b`) only FILLS keys the survivor is missing. Never
997
+ * overwrites a populated survivor field. `undefined` when both are empty.
998
+ */
999
+ function unionMetadata(a, b) {
1000
+ if (a === void 0 && b === void 0) return void 0;
1001
+ return {
1002
+ ...b ?? {},
1003
+ ...a ?? {}
1004
+ };
1005
+ }
611
1006
  //#endregion
612
1007
  //#region src/events/event-constants.ts
613
1008
  /** PACKAGE_RULES §12: `package:resource.verb`. Emit ONLY these catalog names. */
@@ -617,10 +1012,12 @@ const PARTY_EVENTS = {
617
1012
  ROLE_ADDED: "party:party.role_added",
618
1013
  ROLE_REMOVED: "party:party.role_removed",
619
1014
  USER_LINKED: "party:party.user_linked",
1015
+ CONSENT_RECORDED: "party:party.consent_recorded",
620
1016
  EXTERNAL_REF_ATTACHED: "party:party.external_ref_attached",
621
1017
  ARCHIVED: "party:party.archived",
622
1018
  RESTORED: "party:party.restored",
623
- MERGED: "party:party.merged"
1019
+ MERGED: "party:party.merged",
1020
+ RELATIONSHIP_CHANGED: "party:party.relationship_changed"
624
1021
  };
625
1022
  //#endregion
626
1023
  //#region src/events/helpers.ts
@@ -665,9 +1062,15 @@ function repoOptionsFromCtx(ctx) {
665
1062
  const OBJECT_ID_HEX = /^[a-f0-9]{24}$/i;
666
1063
  var PartyRepository = class extends Repository {
667
1064
  events;
1065
+ /** The ONE interpretation of free-form contact input in this deployment. */
1066
+ normalize;
1067
+ /** Contact data can exist without being strong enough to collapse two parties. */
1068
+ identityContactTypes;
668
1069
  constructor(model, deps, plugins = []) {
669
1070
  super(model, plugins);
670
1071
  this.events = deps.events;
1072
+ this.normalize = deps.defaultPhoneRegion !== void 0 ? { defaultRegion: deps.defaultPhoneRegion } : {};
1073
+ this.identityContactTypes = deps.identityContactTypes ?? ["email", "phone"];
671
1074
  }
672
1075
  /** `update` that never returns null — the party was loaded first; a null means a concurrent delete. */
673
1076
  async mustUpdate(id, patch, ctx) {
@@ -676,9 +1079,9 @@ var PartyRepository = class extends Repository {
676
1079
  return updated;
677
1080
  }
678
1081
  async resolveOrCreate(input, ctx) {
679
- const contactPoints = buildContactPoints(input);
680
- const keys = deriveIdentityKeys(contactPoints, input);
681
- if (keys.length === 0 && !input.displayName && !input.personName) throw new PartyValidationError("resolveOrCreate needs at least one identity signal (email/phone/vat/bin/externalRef) or a displayName.");
1082
+ const contactPoints = buildContactPoints(input, this.normalize);
1083
+ const keys = deriveIdentityKeys(contactPoints, input, { contactTypes: this.identityContactTypes });
1084
+ if (keys.length === 0 && contactPoints.length === 0 && !input.displayName?.trim() && !input.personName && !input.legalName?.trim()) throw new PartyValidationError("resolveOrCreate needs at least one contact/identity signal (email/phone/vat/bin/externalRef) or a name.");
682
1085
  if (keys.length > 0) {
683
1086
  const existing = await this.getByQuery({
684
1087
  identityKeys: { $in: keys },
@@ -770,17 +1173,24 @@ var PartyRepository = class extends Repository {
770
1173
  */
771
1174
  async syncIdentity(id, input, ctx) {
772
1175
  const party = await this.loadParty(id, ctx);
773
- const mergedContacts = unionContacts(party.contactPoints, buildContactPoints(input));
1176
+ const mergedContacts = unionContacts(party.contactPoints, buildContactPoints(input, this.normalize));
1177
+ const mergedKeys = unionStrings(party.identityKeys, deriveIdentityKeys(mergedContacts, input, { contactTypes: this.identityContactTypes }));
774
1178
  const set = {
775
1179
  contactPoints: mergedContacts,
776
- identityKeys: unionStrings(party.identityKeys, deriveIdentityKeys(mergedContacts, input))
1180
+ identityKeys: mergedKeys
777
1181
  };
778
1182
  if (input.displayName?.trim()) set.displayName = input.displayName.trim();
779
1183
  if (input.legalName?.trim()) set.legalName = input.legalName.trim();
780
1184
  if (input.personName !== void 0) set.personName = input.personName;
781
1185
  if (input.addresses !== void 0) set.addresses = input.addresses;
782
1186
  if (input.taxIdentity !== void 0) set.taxIdentity = input.taxIdentity;
783
- const updated = await this.mustUpdate(party._id, { $set: set }, ctx);
1187
+ let updated;
1188
+ try {
1189
+ updated = await this.mustUpdate(party._id, { $set: set }, ctx);
1190
+ } catch (err) {
1191
+ if (isDuplicateKeyError(err)) throw new PartyResolveConflictError(mergedKeys.find((k) => !party.identityKeys.includes(k)) ?? mergedKeys[0] ?? "");
1192
+ throw err;
1193
+ }
784
1194
  await this.dispatch(PARTY_EVENTS.UPDATED, {
785
1195
  partyId: String(party._id),
786
1196
  changed: Object.keys(set)
@@ -807,6 +1217,65 @@ var PartyRepository = class extends Repository {
807
1217
  }, updated, ctx);
808
1218
  return updated;
809
1219
  }
1220
+ /**
1221
+ * Record a dated relationship (shareholder, director, lessor, …) to the reporting company or another
1222
+ * party. Refused when one of the same kind with the same counterpart is already in force over the dates:
1223
+ * two overlapping rows would double a shareholding, and only one of them could be ended.
1224
+ */
1225
+ async addRelationship(id, input, ctx) {
1226
+ const party = await this.loadParty(id, ctx);
1227
+ const relatedTo = input.relatedTo ?? null;
1228
+ const to = input.to ?? null;
1229
+ if (to && to <= input.from) throw new PartyRelationshipError("A relationship ends after it starts.", id);
1230
+ if (party.relationships.find((r) => r.kind === input.kind && String(r.relatedTo ?? "") === String(relatedTo ?? "") && overlaps(r.from, r.to ?? null, input.from, to))) throw new PartyRelationshipError(`A ${input.kind} relationship already holds over those dates.`, id);
1231
+ const row = {
1232
+ _id: new Types.ObjectId(),
1233
+ kind: input.kind,
1234
+ relatedTo,
1235
+ from: input.from,
1236
+ to,
1237
+ shareClass: input.shareClass ?? null,
1238
+ sharePercent: input.sharePercent ?? null,
1239
+ note: input.note ?? null
1240
+ };
1241
+ const updated = await this.mustUpdate(party._id, { $push: { relationships: row } }, ctx);
1242
+ await this.dispatch(PARTY_EVENTS.RELATIONSHIP_CHANGED, {
1243
+ partyId: String(party._id),
1244
+ relationshipId: String(row._id),
1245
+ kind: input.kind,
1246
+ change: "added"
1247
+ }, updated, ctx);
1248
+ return updated;
1249
+ }
1250
+ /** End a relationship on a date (exclusive). Ending one already ended earlier is refused, never moved. */
1251
+ async endRelationship(id, relationshipId, to, ctx) {
1252
+ const party = await this.loadParty(id, ctx);
1253
+ const row = party.relationships.find((r) => String(r._id) === relationshipId);
1254
+ if (!row) throw new PartyRelationshipError(`Relationship ${relationshipId} not found.`, id);
1255
+ if (row.to) throw new PartyRelationshipError("The relationship has already ended.", id);
1256
+ if (to <= row.from) throw new PartyRelationshipError("A relationship ends after it starts.", id);
1257
+ const updated = await this.update(party._id, { $set: { "relationships.$[r].to": to } }, {
1258
+ ...repoOptionsFromCtx(ctx),
1259
+ arrayFilters: [{
1260
+ "r._id": row._id,
1261
+ "r.to": null
1262
+ }]
1263
+ });
1264
+ if (!updated) throw new PartyNotFoundError(id);
1265
+ await this.dispatch(PARTY_EVENTS.RELATIONSHIP_CHANGED, {
1266
+ partyId: String(party._id),
1267
+ relationshipId,
1268
+ kind: row.kind,
1269
+ change: "ended"
1270
+ }, updated, ctx);
1271
+ return updated;
1272
+ }
1273
+ /** The relationships in force on a date — following a merged party to its survivor. */
1274
+ async relationshipsOn(id, on, ctx) {
1275
+ let party = await this.loadParty(id, ctx);
1276
+ if (party.status === "merged" && party.mergedInto) party = await this.loadParty(String(party.mergedInto), ctx);
1277
+ return party.relationships.filter((r) => r.from <= on && (!r.to || on < r.to));
1278
+ }
810
1279
  async linkUser(id, userId, ctx) {
811
1280
  const party = await this.loadParty(id, ctx);
812
1281
  if (party.userLinks.includes(userId)) return party;
@@ -817,10 +1286,64 @@ var PartyRepository = class extends Repository {
817
1286
  }, updated, ctx);
818
1287
  return updated;
819
1288
  }
1289
+ /**
1290
+ * Record what this person said about being MARKETED to on one channel.
1291
+ *
1292
+ * Addressed by the channel's raw value (what they typed) and normalized here,
1293
+ * so a host passing `07700 900000` and a host passing `+447700900000` reach
1294
+ * the same contact point — consent landing on a second, differently-spelled
1295
+ * row is an opt-out that no sender would ever find.
1296
+ *
1297
+ * A channel the party does not hold is a typed error rather than a silent
1298
+ * no-op: a grant that went nowhere looks identical to one that was stored,
1299
+ * and the caller is usually a booking form that will never check again.
1300
+ */
1301
+ async recordConsent(id, input, ctx) {
1302
+ const party = await this.loadParty(id, ctx);
1303
+ const canonical = input.channel === "email" ? normalizeEmail(input.value) : normalizePhone$1(input.value, this.normalize);
1304
+ const key = `${input.channel}:${canonical}`;
1305
+ const index = party.contactPoints.findIndex((point) => point.key === key);
1306
+ if (index < 0) throw new PartyValidationError(`Party ${id} has no ${input.channel} contact point for ${canonical}; consent would be unreachable.`);
1307
+ const consent = {
1308
+ status: input.status,
1309
+ at: input.at ?? /* @__PURE__ */ new Date(),
1310
+ ...input.source !== void 0 ? { source: input.source } : {},
1311
+ ...input.status === "granted" && input.ip !== void 0 ? { ip: input.ip } : {}
1312
+ };
1313
+ const updated = await this.mustUpdate(party._id, { $set: { [`contactPoints.${index}.consent`]: consent } }, ctx);
1314
+ await this.dispatch(PARTY_EVENTS.CONSENT_RECORDED, {
1315
+ partyId: String(party._id),
1316
+ channel: input.channel,
1317
+ value: canonical,
1318
+ status: input.status,
1319
+ ...consent.source !== void 0 ? { source: consent.source } : {}
1320
+ }, updated, ctx);
1321
+ return updated;
1322
+ }
820
1323
  async attachExternalRef(id, ref, ctx) {
821
1324
  const party = await this.loadParty(id, ctx);
822
1325
  if (party.externalRefs.some((r) => r.sourceModel === ref.sourceModel && r.sourceId === ref.sourceId)) return party;
823
- const updated = await this.mustUpdate(party._id, { $addToSet: { externalRefs: ref } }, ctx);
1326
+ const refKey = `ref:${ref.sourceModel}:${ref.sourceId}`;
1327
+ let updated;
1328
+ try {
1329
+ updated = await this.mustUpdate(party._id, { $addToSet: {
1330
+ externalRefs: ref,
1331
+ identityKeys: refKey
1332
+ } }, ctx);
1333
+ } catch (err) {
1334
+ if (isDuplicateKeyError(err)) {
1335
+ const owner = await this.getByQuery({
1336
+ identityKeys: refKey,
1337
+ status: { $ne: "merged" }
1338
+ }, {
1339
+ ...repoOptionsFromCtx(ctx),
1340
+ throwOnNotFound: false
1341
+ });
1342
+ if (owner && String(owner._id) === String(party._id)) return owner;
1343
+ throw new PartyResolveConflictError(refKey);
1344
+ }
1345
+ throw err;
1346
+ }
824
1347
  await this.dispatch(PARTY_EVENTS.EXTERNAL_REF_ATTACHED, {
825
1348
  partyId: String(party._id),
826
1349
  sourceModel: ref.sourceModel,
@@ -852,9 +1375,17 @@ var PartyRepository = class extends Repository {
852
1375
  }
853
1376
  /**
854
1377
  * Merge `sourceId` into `targetId`: the survivor absorbs the loser's identity
855
- * keys, contact points, addresses, roles, external refs, and user links; the
856
- * loser becomes a terminal `merged` redirect (`mergedInto`). Two-document
857
- * write → runs in a transaction (falls back to sequential on standalone Mongo).
1378
+ * keys, contact points, addresses, roles, external refs, user links, tags, and
1379
+ * metadata, and FILLS its empty scalar slots (`taxIdentity`, `personName`,
1380
+ * `legalName`, `parentId`) from the loser — never overwriting a populated
1381
+ * survivor field. The loser becomes a terminal `merged` redirect
1382
+ * (`mergedInto`). Two-document write → runs in a transaction (falls back to
1383
+ * sequential on standalone Mongo).
1384
+ *
1385
+ * Concurrency: the loser is claimed `active|archived → merged` via CAS
1386
+ * (`repo.claim`), so a second concurrent merge of the same source loses the
1387
+ * race and surfaces a typed `PartyMergeError` — never a raw `E11000`. A
1388
+ * duplicate-key collision while adopting the loser's keys maps to the same.
858
1389
  */
859
1390
  async merge(sourceId, targetId, ctx) {
860
1391
  if (sourceId === targetId) throw new PartyMergeError("Cannot merge a party into itself.", sourceId, targetId);
@@ -867,27 +1398,51 @@ var PartyRepository = class extends Repository {
867
1398
  const mergedUsers = unionStrings(target.userLinks, source.userLinks);
868
1399
  const mergedRefs = unionRefs(target.externalRefs, source.externalRefs);
869
1400
  const mergedContacts = unionContacts(target.contactPoints, source.contactPoints);
1401
+ const mergedAddresses = unionAddresses(target.addresses, source.addresses);
1402
+ const mergedTags = unionStrings(target.tags, source.tags);
1403
+ const mergedMetadata = unionMetadata(target.metadata, source.metadata);
1404
+ const relKey = (r) => `${r.kind}|${String(r.relatedTo ?? "")}|${new Date(r.from).toISOString()}`;
1405
+ const ownRel = new Set(target.relationships.map(relKey));
1406
+ const mergedRelationships = [...target.relationships, ...source.relationships.filter((r) => !ownRel.has(relKey(r)))];
870
1407
  const absorbed = mergedKeys.length - target.identityKeys.length;
871
- await withTransaction(this.Model.db, async (session) => {
872
- await this.update(source._id, { $set: {
873
- status: "merged",
874
- mergedInto: target._id,
875
- identityKeys: []
876
- } }, {
877
- ...repoOptionsFromCtx(ctx),
878
- session
879
- });
880
- await this.update(target._id, { $set: {
881
- identityKeys: mergedKeys,
882
- roles: mergedRoles,
883
- userLinks: mergedUsers,
884
- externalRefs: mergedRefs,
885
- contactPoints: mergedContacts
886
- } }, {
887
- ...repoOptionsFromCtx(ctx),
888
- session
889
- });
890
- }, { allowFallback: true });
1408
+ const fill = {};
1409
+ if (target.taxIdentity === void 0 && source.taxIdentity !== void 0) fill.taxIdentity = source.taxIdentity;
1410
+ if (target.personName === void 0 && source.personName !== void 0) fill.personName = source.personName;
1411
+ if (!target.legalName?.trim() && source.legalName?.trim()) fill.legalName = source.legalName;
1412
+ if (target.parentId === void 0 && source.parentId !== void 0) fill.parentId = source.parentId;
1413
+ if (target.legalForm === void 0 && source.legalForm !== void 0) fill.legalForm = source.legalForm;
1414
+ try {
1415
+ await withTransaction(this.Model.db, async (session) => {
1416
+ if (!await this.claim(String(source._id), {
1417
+ from: ["active", "archived"],
1418
+ to: "merged"
1419
+ }, { $set: {
1420
+ mergedInto: target._id,
1421
+ identityKeys: []
1422
+ } }, {
1423
+ ...repoOptionsFromCtx(ctx),
1424
+ session
1425
+ })) throw new PartyMergeError("Source party was concurrently modified (no longer mergeable).", sourceId, targetId);
1426
+ await this.update(target._id, { $set: {
1427
+ identityKeys: mergedKeys,
1428
+ roles: mergedRoles,
1429
+ userLinks: mergedUsers,
1430
+ externalRefs: mergedRefs,
1431
+ contactPoints: mergedContacts,
1432
+ addresses: mergedAddresses,
1433
+ tags: mergedTags,
1434
+ relationships: mergedRelationships,
1435
+ ...fill,
1436
+ ...mergedMetadata !== void 0 ? { metadata: mergedMetadata } : {}
1437
+ } }, {
1438
+ ...repoOptionsFromCtx(ctx),
1439
+ session
1440
+ });
1441
+ }, { allowFallback: true });
1442
+ } catch (err) {
1443
+ if (isDuplicateKeyError(err)) throw new PartyMergeError("Concurrent merge collided on a shared identity key.", sourceId, targetId);
1444
+ throw err;
1445
+ }
891
1446
  const survivor = await this.loadParty(targetId, ctx);
892
1447
  await this.dispatch(PARTY_EVENTS.MERGED, {
893
1448
  sourceId,
@@ -930,114 +1485,45 @@ var PartyRepository = class extends Repository {
930
1485
  }), ctx);
931
1486
  }
932
1487
  };
1488
+ const overlaps = (aFrom, aTo, bFrom, bTo) => aFrom < (bTo ?? /* @__PURE__ */ new Date(864e13)) && bFrom < (aTo ?? /* @__PURE__ */ new Date(864e13));
933
1489
  //#endregion
934
- //#region src/events/dispatch.ts
935
- /** Context key carrying a post-commit publish queue (attached by `withTransaction`). */
936
- const PENDING_EVENTS = Symbol.for("@classytic/party:pendingEvents");
937
- var PartyEventDispatcher = class {
938
- name;
939
- transport;
940
- outbox;
941
- logger;
942
- constructor(opts) {
943
- this.transport = opts.transport;
944
- this.outbox = opts.outbox;
945
- this.logger = opts.logger;
946
- this.name = opts.transport.name;
947
- }
948
- /** Raw publish — hosts and the unit-of-work flush use this. */
949
- publish(event) {
950
- return this.transport.publish(event);
951
- }
952
- subscribe(pattern, handler) {
953
- return this.transport.subscribe(pattern, handler);
954
- }
955
- /**
956
- * Route a domain event with full §P8 discipline. Reads the pending queue and
957
- * session off `ctx`.
958
- */
959
- async dispatch(event, ctx) {
960
- if (this.outbox) try {
961
- await this.outbox.save(event, ctx.session !== void 0 ? { session: ctx.session } : {});
962
- } catch (err) {
963
- this.logger?.error?.(`[party] outbox.save failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
964
- throw err;
965
- }
966
- const queue = ctx[PENDING_EVENTS];
967
- if (Array.isArray(queue)) {
968
- queue.push(event);
969
- return;
970
- }
971
- try {
972
- await this.transport.publish(event);
973
- } catch (err) {
974
- this.logger?.error?.(`[party] publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
975
- }
976
- }
977
- async close() {
978
- await this.transport.close?.();
979
- }
980
- };
981
- /**
982
- * Drain a pending-events queue to the transport AFTER the owning transaction
983
- * commits. Per-event failures are swallowed — durable delivery is the outbox's
984
- * job (PACKAGE_RULES §P8.1).
985
- */
986
- async function flushPending(dispatcher, queue, logger) {
987
- for (const event of queue) try {
988
- await dispatcher.publish(event);
989
- } catch (err) {
990
- logger?.error?.(`[party] post-commit publish failed for ${event.type}`, { error: err instanceof Error ? err.message : String(err) });
991
- }
992
- }
993
- //#endregion
994
- //#region src/events/in-process-bus.ts
1490
+ //#region src/engine/ensure-ready.ts
995
1491
  /**
996
- * In-process event bus — default fallback when no `eventTransport` is given to
997
- * `createParty`. Structurally identical to arc's `MemoryEventTransport`;
998
- * pattern-matching delegates to primitives' `matchEventPattern` (PACKAGE_RULES
999
- * §13). Handler errors are swallowed-and-logged — emission never blocks writes.
1492
+ * Boot capability gate (PACKAGE_RULES §capabilities). `resolveOrCreate`'s
1493
+ * race-safety leans on the unique-index E11000 path, and `merge` writes two
1494
+ * documents in a transaction — so the backend MUST support `upsert` +
1495
+ * `duplicateKeyError`, and `transactions` (unless the host opts out).
1496
+ * Fail loud at engine creation, not with a cryptic error on the first merge.
1497
+ *
1498
+ * ## This gate could not fire until 2026-08-05
1499
+ *
1500
+ * `repo.capabilities` used to resolve to mongokit's STATIC
1501
+ * `MONGOKIT_CAPABILITIES`, whose `transactions` was hard-coded `true`. So the
1502
+ * check below was structurally unfalsifiable for every mongoose-backed
1503
+ * repository — including one pointed at a standalone `mongod`, the exact
1504
+ * deployment it exists to catch. (party's kernel-conformance run had to SKIP
1505
+ * `bind-verifies-requirements` for precisely this reason.) mongokit now
1506
+ * OBSERVES the live deployment topology per connection, so the same code path
1507
+ * now genuinely refuses a standalone.
1508
+ *
1509
+ * ## Three answers, not two
1510
+ *
1511
+ * `transactionResolutionOf(caps)` distinguishes an OBSERVED "no" (a standalone
1512
+ * — fix the deployment) from "we could not determine it" (bound before the
1513
+ * connection opened, an unreadable driver). Both refuse, because an unobserved
1514
+ * outcome is not a positive one (AGENTS.md FAIL LOUD rule 3) — but the message
1515
+ * differs, because the operator's fix does.
1000
1516
  */
1001
- var InProcessPartyBus = class {
1002
- name = "in-process-party";
1003
- subs = [];
1004
- logger;
1005
- constructor(options) {
1006
- this.logger = options?.logger;
1007
- }
1008
- async publish(event) {
1009
- const matching = this.subs.filter((s) => matchEventPattern(s.pattern, event.type));
1010
- for (const sub of matching) try {
1011
- await sub.handler(event);
1012
- } catch (err) {
1013
- this.logger?.error?.(`[party] event handler error [${event.type}]`, { error: err instanceof Error ? err.message : String(err) });
1014
- }
1015
- }
1016
- async subscribe(pattern, handler) {
1017
- const sub = {
1018
- pattern,
1019
- handler
1020
- };
1021
- this.subs.push(sub);
1022
- return () => {
1023
- this.subs = this.subs.filter((s) => s !== sub);
1024
- };
1025
- }
1026
- async close() {
1027
- this.subs = [];
1028
- }
1029
- };
1030
- //#endregion
1031
- //#region src/engine/ensure-ready.ts
1032
1517
  function assertPartyCapabilities(repo, opts = {}) {
1033
1518
  const caps = repo.capabilities;
1034
1519
  if (!caps) throw new Error("[party] backend declares no `capabilities` descriptor (needs repo-core 0.6 / mongokit 3.16+).");
1035
1520
  const missing = ["duplicateKeyError"].filter((flag) => caps[flag] !== true);
1036
1521
  if (missing.length > 0) throw new Error(`[party] backend missing required capabilities: ${missing.join(", ")}. resolveOrCreate relies on unique-index conflict detection for race-safe identity dedup.`);
1037
1522
  if (caps.transactions !== true) {
1038
- const msg = "[party] backend does not support transactions — `merge` writes two documents and needs a replica set for atomicity.";
1039
- if (opts.allowNonTransactional) opts.logger?.warn?.(`${msg} allowNonTransactional=true: merge falls back to sequential writes (partial-write risk).`);
1040
- else throw new Error(`${msg} Run MongoDB as a replica set, or pass allowNonTransactional: true.`);
1523
+ const indeterminate = transactionResolutionOf(caps) === "unknown";
1524
+ const cause = indeterminate ? "[party] could NOT determine whether the backend supports transactions (the connection was not open at bind time, or the driver exposed no topology). An unconfirmed answer is treated as unsupported — `merge` writes two documents and needs atomicity." : "[party] backend does not support transactions — `merge` writes two documents and needs a replica set for atomicity.";
1525
+ if (opts.allowNonTransactional) opts.logger?.warn?.(`${cause} allowNonTransactional=true: merge falls back to sequential writes (partial-write risk).`);
1526
+ else throw new Error(`${cause} ${indeterminate ? "Bind AFTER `await mongoose.connect(...)`, or `await probeMongoCapabilities(connection)` first, or pass allowNonTransactional: true." : "Run MongoDB as a replica set, or pass allowNonTransactional: true."}`);
1041
1527
  }
1042
1528
  }
1043
1529
  //#endregion
@@ -1072,83 +1558,173 @@ async function runUnit(config, ctx, body) {
1072
1558
  return result;
1073
1559
  }
1074
1560
  //#endregion
1075
- //#region src/engine/create-party.ts
1561
+ //#region src/engine/define-party.ts
1076
1562
  /**
1077
- * createParty — engine factory for @classytic/party.
1078
- *
1079
- * 1. Validate `connection`.
1080
- * 2. Resolve defaults (multiTenant=true, tenantFieldType=objectId, …).
1081
- * 3. Create the Party model on the connection (collision policy §21).
1082
- * 4. Resolve the EventTransport (or default in-process bus) + dispatcher.
1083
- * 5. Build the repository — plugins composed in canonical order (§9.2).
1084
- * 6. Boot-gate required capabilities, then freeze + return the engine.
1563
+ * `defineParty` — the DESCRIBE/BIND blueprint for the party engine
1564
+ * (STANDARDIZATION-PLAN §6). Splits construction into two phases:
1085
1565
  *
1086
- * @example
1087
- * ```ts
1088
- * import mongoose from 'mongoose';
1089
- * import { createParty } from '@classytic/party';
1566
+ * - **describe** (`defineParty(shape)`): pure. Validates the model set and freezes the
1567
+ * model names via mongokit's `defineModels()`. No connection, no schema built, no I/O —
1568
+ * safe to call at import / module-composition time.
1569
+ * - **bind** (`blueprint.bind(connection, runtime)`): compiles the schema on the supplied
1570
+ * connection, registers the model, wires the transport/dispatcher/repository, verifies
1571
+ * capabilities, and returns the frozen `PartyEngine`.
1090
1572
  *
1091
- * const party = await createParty({ connection: mongoose.connection, multiTenant: false });
1092
- *
1093
- * const { party: p } = await party.port.resolveOrCreate(
1094
- * { displayName: 'Ada Lovelace', emails: ['ada@x.com'], roles: ['customer'] },
1095
- * {},
1096
- * );
1097
- * ```
1573
+ * This is the sole construction API — there is no `createParty` wrapper.
1098
1574
  */
1099
- async function createParty(config) {
1100
- if (!config.connection) throw new Error("[party] PartyConfig.connection is required");
1101
- const resolved = {
1102
- connection: config.connection,
1103
- multiTenant: config.multiTenant ?? true,
1104
- tenantFieldType: config.tenantFieldType ?? "objectId",
1105
- forceRecreate: config.forceRecreate ?? false,
1106
- autoIndex: config.autoIndex ?? true,
1107
- allowNonTransactional: config.allowNonTransactional ?? false,
1108
- ...config.logger !== void 0 ? { logger: config.logger } : {},
1109
- ...config.pagination !== void 0 ? { pagination: config.pagination } : {},
1110
- plugins: config.plugins ?? []
1111
- };
1112
- const models = createPartyModels(config.connection, resolved);
1113
- const events = new PartyEventDispatcher({
1114
- transport: config.eventTransport ?? new InProcessPartyBus(config.logger ? { logger: config.logger } : void 0),
1115
- ...config.outbox !== void 0 ? { outbox: config.outbox } : {},
1116
- ...config.logger !== void 0 ? { logger: config.logger } : {}
1117
- });
1118
- const plugins = [...resolved.multiTenant ? [multiTenantPlugin({
1119
- tenantField: "organizationId",
1120
- fieldType: resolved.tenantFieldType,
1121
- required: true
1122
- })] : [], ...resolved.plugins];
1123
- const repository = new PartyRepository(models.Party, {
1124
- events,
1125
- ...resolved.logger !== void 0 ? { logger: resolved.logger } : {}
1126
- }, plugins);
1127
- assertPartyCapabilities(repository, {
1128
- allowNonTransactional: resolved.allowNonTransactional,
1129
- ...resolved.logger?.warn !== void 0 ? { logger: { warn: (m) => resolved.logger?.warn?.(m) } } : {}
1575
+ /**
1576
+ * Describe the party engine. Pure — registers no model, builds no schema, performs no I/O.
1577
+ */
1578
+ function defineParty(shape = {}) {
1579
+ /**
1580
+ * The pre-consolidation tenant keys are REFUSED, never absorbed.
1581
+ *
1582
+ * This entry reads `shape.tenant`, so a leftover `multiTenant` is invisible and
1583
+ * the `?? true` default applies repo-core's defaults instead. Both outcomes
1584
+ * return plausible data:
1585
+ * - `multiTenant: false` -> tenancy ON, and the host loses its company-wide reads;
1586
+ * - `multiTenant: { tenantField: 'business' }` -> scoping on `organizationId`,
1587
+ * a field those documents do not carry.
1588
+ *
1589
+ * tsc cannot cover this: excess-property checking does not fire for a typed
1590
+ * variable, a spread, or a shape handed over from a dependency's dist — which is
1591
+ * how every real host composes one. Hence a describe-time check, shared from
1592
+ * repo-core so the nine kernels making this same rename cannot each forget it.
1593
+ */
1594
+ assertNoLegacyTenantKeys(shape, "defineParty", [["tenantFieldType", "tenant.fieldType"]]);
1595
+ const tenant = resolveTenantConfig(shape.tenant ?? true);
1596
+ if (tenant.strategy === "custom") throw new Error("defineParty: tenant.strategy 'custom' is not supported");
1597
+ const autoIndex = shape.autoIndex ?? true;
1598
+ const identityContactTypes = [...new Set(shape.identityContactTypes ?? CONTACT_POINT_TYPES)];
1599
+ const invalidIdentityContactType = identityContactTypes.find((type) => !CONTACT_POINT_TYPES.includes(type));
1600
+ if (invalidIdentityContactType !== void 0) throw new Error(`defineParty: identityContactTypes contains unsupported value '${String(invalidIdentityContactType)}'. Expected: ${CONTACT_POINT_TYPES.join(", ")}.`);
1601
+ const modelBlueprint = definePartyModels({
1602
+ tenant,
1603
+ autoIndex,
1604
+ ...shape.forceRecreate !== void 0 ? { forceRecreate: shape.forceRecreate } : {}
1130
1605
  });
1131
- return Object.freeze({
1132
- models,
1133
- repositories: Object.freeze({ party: repository }),
1134
- port: repository,
1135
- events,
1136
- config: Object.freeze(resolved),
1137
- withTransaction(ctx, body) {
1138
- return runUnit({
1139
- connection: config.connection,
1606
+ return {
1607
+ id: "party",
1608
+ modelNames: modelBlueprint.modelNames,
1609
+ bind(connection, runtime = {}) {
1610
+ /**
1611
+ * BOOT GATE — an injected outbox MUST enlist `ctx.session`.
1612
+ *
1613
+ * FIRST statement, before the model blueprint binds: a pure config
1614
+ * assertion should report before any resource is allocated, and a refused
1615
+ * bind must leave the connection's model registry untouched.
1616
+ *
1617
+ * `OutboxWriteOptions.session` is best-effort BY CONTRACT (see
1618
+ * `@classytic/primitives/outbox`), so a hand-rolled store satisfies
1619
+ * `OutboxStore` and then keeps the row when the transaction aborts.
1620
+ * `PartyEventDispatcher.dispatch` saves under `ctx.session` precisely so it
1621
+ * cannot: identity is the ROOT every facet keys off, and a
1622
+ * `party.merged` that survives a rolled-back merge tells every downstream
1623
+ * consumer — customer, supplier, loyalty, accounting — to re-point at a
1624
+ * party that was never merged. Nothing throws; the divergence shows up as
1625
+ * facets pointing at an id that does not exist.
1626
+ *
1627
+ * Discovering that on the first real merge is far worse than at boot.
1628
+ */
1629
+ if (runtime.outbox && runtime.outbox.transactionalSave !== true) throw new Error("defineParty.bind: the configured OutboxStore does not declare `transactionalSave: true`, so it does not promise that `save()` enlists `ctx.session` (see @classytic/primitives/outbox — the option is best-effort by contract). Party writes every event row inside the transaction of its caller; a store that ignores the session emits merge/archive events for identities that rolled back. Arc's `repositoryAsOutboxStore` declares it; the in-memory `MemoryOutboxStore` must not — pass no outbox instead.");
1630
+ const models = modelBlueprint.bind(connection);
1631
+ const resolved = {
1632
+ connection,
1633
+ tenant,
1634
+ forceRecreate: shape.forceRecreate ?? false,
1635
+ autoIndex,
1636
+ identityContactTypes: Object.freeze([...identityContactTypes]),
1637
+ ...shape.defaultPhoneRegion !== void 0 ? { defaultPhoneRegion: shape.defaultPhoneRegion } : {},
1638
+ allowNonTransactional: runtime.allowNonTransactional ?? false,
1639
+ ...runtime.logger !== void 0 ? { logger: runtime.logger } : {},
1640
+ ...runtime.pagination !== void 0 ? { pagination: runtime.pagination } : {},
1641
+ plugins: runtime.plugins ?? []
1642
+ };
1643
+ const ownsTransport = runtime.eventTransport === void 0;
1644
+ const events = new PartyEventDispatcher({
1645
+ transport: runtime.eventTransport ?? new InProcessPartyBus(runtime.logger ? { logger: runtime.logger } : void 0),
1646
+ ...runtime.outbox !== void 0 ? { outbox: runtime.outbox } : {},
1647
+ ...runtime.logger !== void 0 ? { logger: runtime.logger } : {}
1648
+ });
1649
+ const plugins = [...tenant.enabled ? [multiTenantPlugin({
1650
+ tenantField: tenant.tenantField,
1651
+ contextKey: tenant.contextKey,
1652
+ fieldType: tenant.fieldType,
1653
+ required: tenant.required
1654
+ })] : [], ...resolved.plugins];
1655
+ const repository = new PartyRepository(models.Party, {
1140
1656
  events,
1141
1657
  ...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
1142
- allowNonTransactional: resolved.allowNonTransactional
1143
- }, ctx, body);
1144
- },
1145
- async syncIndexes() {
1146
- await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
1147
- },
1148
- async dispose() {
1149
- await events.close();
1658
+ ...resolved.defaultPhoneRegion !== void 0 ? { defaultPhoneRegion: resolved.defaultPhoneRegion } : {},
1659
+ identityContactTypes: resolved.identityContactTypes
1660
+ }, plugins);
1661
+ assertPartyCapabilities(repository, {
1662
+ allowNonTransactional: resolved.allowNonTransactional,
1663
+ ...resolved.logger?.warn !== void 0 ? { logger: { warn: (m) => resolved.logger?.warn?.(m) } } : {}
1664
+ });
1665
+ const close = async () => {
1666
+ if (ownsTransport) await events.close();
1667
+ };
1668
+ return Object.freeze({
1669
+ models,
1670
+ repositories: Object.freeze({ party: repository }),
1671
+ port: repository,
1672
+ events,
1673
+ config: Object.freeze(resolved),
1674
+ withTransaction(ctx, body) {
1675
+ return runUnit({
1676
+ connection,
1677
+ events,
1678
+ ...resolved.logger !== void 0 ? { logger: resolved.logger } : {},
1679
+ allowNonTransactional: resolved.allowNonTransactional
1680
+ }, ctx, body);
1681
+ },
1682
+ async syncIndexes() {
1683
+ await Promise.all(Object.values(models).map((m) => m.syncIndexes()));
1684
+ },
1685
+ close
1686
+ });
1150
1687
  }
1151
- });
1688
+ };
1689
+ }
1690
+ //#endregion
1691
+ //#region src/domain/subject.ts
1692
+ /**
1693
+ * The canonical `subjectModel` value for a Party.
1694
+ *
1695
+ * Import this — never write `'Party'` in a host, a module, or a test.
1696
+ */
1697
+ const PARTY_SUBJECT_MODEL = "Party";
1698
+ /**
1699
+ * Build the subject pair for a Party.
1700
+ *
1701
+ * Accepts anything id-shaped (`ObjectId`, hex string, a document's `_id`) and
1702
+ * stringifies through primitives' `idToString`, because the receiving kernels
1703
+ * store `subjectRef` as a String — a raw `ObjectId` would stringify implicitly
1704
+ * somewhere downstream and the two forms would not compare equal.
1705
+ *
1706
+ * Returns `null` for a missing id rather than a pair containing `"undefined"`.
1707
+ * That string is a real hazard here: it is a *valid* `subjectRef` as far as the
1708
+ * schema is concerned, so it persists, matches nothing, and looks like data.
1709
+ */
1710
+ function partySubject(id) {
1711
+ if (id === null || id === void 0) return null;
1712
+ const subjectRef = idToString(id);
1713
+ if (subjectRef.length === 0 || subjectRef === "undefined" || subjectRef === "null") return null;
1714
+ return {
1715
+ subjectRef,
1716
+ subjectModel: PARTY_SUBJECT_MODEL
1717
+ };
1718
+ }
1719
+ /**
1720
+ * Does this subject pair refer to a Party?
1721
+ *
1722
+ * For a resolver that fans a mixed subject stream out to the right kernel — an
1723
+ * attendance list can hold Party members and, in an HR deployment, employees
1724
+ * from another identity source.
1725
+ */
1726
+ function isPartySubject(subject) {
1727
+ return subject.subjectModel === PARTY_SUBJECT_MODEL;
1152
1728
  }
1153
1729
  //#endregion
1154
1730
  //#region src/events/event-catalog.ts
@@ -1207,6 +1783,13 @@ const userLinkedSchema = z.object({
1207
1783
  partyId: z.string(),
1208
1784
  userId: z.string()
1209
1785
  });
1786
+ const consentRecordedSchema = z.object({
1787
+ partyId: z.string(),
1788
+ channel: z.enum(["email", "phone"]),
1789
+ value: z.string(),
1790
+ status: z.enum(["granted", "withdrawn"]),
1791
+ source: z.string().optional()
1792
+ });
1210
1793
  const externalRefSchema = z.object({
1211
1794
  partyId: z.string(),
1212
1795
  sourceModel: z.string(),
@@ -1218,52 +1801,87 @@ const mergedSchema = z.object({
1218
1801
  targetId: z.string(),
1219
1802
  absorbedKeys: z.number().int().nonnegative()
1220
1803
  });
1804
+ const PartyCreated = definePartyEvent({
1805
+ name: PARTY_EVENTS.CREATED,
1806
+ description: "A new canonical party identity was created.",
1807
+ zodSchema: createdSchema
1808
+ });
1809
+ const PartyUpdated = definePartyEvent({
1810
+ name: PARTY_EVENTS.UPDATED,
1811
+ description: "A party identity field changed.",
1812
+ zodSchema: updatedSchema
1813
+ });
1814
+ const PartyRoleAdded = definePartyEvent({
1815
+ name: PARTY_EVENTS.ROLE_ADDED,
1816
+ description: "A role (customer/supplier/…) was granted to a party.",
1817
+ zodSchema: roleChangedSchema
1818
+ });
1819
+ const PartyRoleRemoved = definePartyEvent({
1820
+ name: PARTY_EVENTS.ROLE_REMOVED,
1821
+ description: "A role was revoked from a party.",
1822
+ zodSchema: roleChangedSchema
1823
+ });
1824
+ const PartyUserLinked = definePartyEvent({
1825
+ name: PARTY_EVENTS.USER_LINKED,
1826
+ description: "An auth user was linked to a party (1:many).",
1827
+ zodSchema: userLinkedSchema
1828
+ });
1829
+ const PartyConsentRecorded = definePartyEvent({
1830
+ name: PARTY_EVENTS.CONSENT_RECORDED,
1831
+ description: "A marketing-consent decision was recorded against one contact channel.",
1832
+ zodSchema: consentRecordedSchema
1833
+ });
1834
+ const PartyExternalRefAttached = definePartyEvent({
1835
+ name: PARTY_EVENTS.EXTERNAL_REF_ATTACHED,
1836
+ description: "An external-store reference was attached to a party (facet link).",
1837
+ zodSchema: externalRefSchema
1838
+ });
1839
+ const PartyArchived = definePartyEvent({
1840
+ name: PARTY_EVENTS.ARCHIVED,
1841
+ description: "A party was archived.",
1842
+ zodSchema: lifecycleSchema
1843
+ });
1844
+ const PartyRestored = definePartyEvent({
1845
+ name: PARTY_EVENTS.RESTORED,
1846
+ description: "An archived party was restored to active.",
1847
+ zodSchema: lifecycleSchema
1848
+ });
1849
+ const PartyMerged = definePartyEvent({
1850
+ name: PARTY_EVENTS.MERGED,
1851
+ description: "A duplicate party was merged into a survivor; the source becomes a redirect.",
1852
+ zodSchema: mergedSchema
1853
+ });
1854
+ const relationshipChangedSchema = z.object({
1855
+ partyId: z.string(),
1856
+ relationshipId: z.string(),
1857
+ kind: z.enum([
1858
+ "shareholder",
1859
+ "director",
1860
+ "key_management",
1861
+ "partner_in_firm",
1862
+ "member",
1863
+ "employee",
1864
+ "lessor",
1865
+ "associate"
1866
+ ]),
1867
+ change: z.enum(["added", "ended"])
1868
+ });
1221
1869
  const partyEventDefinitions = [
1870
+ PartyCreated,
1871
+ PartyUpdated,
1872
+ PartyRoleAdded,
1873
+ PartyRoleRemoved,
1874
+ PartyUserLinked,
1875
+ PartyConsentRecorded,
1876
+ PartyExternalRefAttached,
1877
+ PartyArchived,
1878
+ PartyRestored,
1879
+ PartyMerged,
1222
1880
  definePartyEvent({
1223
- name: PARTY_EVENTS.CREATED,
1224
- description: "A new canonical party identity was created.",
1225
- zodSchema: createdSchema
1226
- }),
1227
- definePartyEvent({
1228
- name: PARTY_EVENTS.UPDATED,
1229
- description: "A party identity field changed.",
1230
- zodSchema: updatedSchema
1231
- }),
1232
- definePartyEvent({
1233
- name: PARTY_EVENTS.ROLE_ADDED,
1234
- description: "A role (customer/supplier/…) was granted to a party.",
1235
- zodSchema: roleChangedSchema
1236
- }),
1237
- definePartyEvent({
1238
- name: PARTY_EVENTS.ROLE_REMOVED,
1239
- description: "A role was revoked from a party.",
1240
- zodSchema: roleChangedSchema
1241
- }),
1242
- definePartyEvent({
1243
- name: PARTY_EVENTS.USER_LINKED,
1244
- description: "An auth user was linked to a party (1:many).",
1245
- zodSchema: userLinkedSchema
1246
- }),
1247
- definePartyEvent({
1248
- name: PARTY_EVENTS.EXTERNAL_REF_ATTACHED,
1249
- description: "An external-store reference was attached to a party (facet link).",
1250
- zodSchema: externalRefSchema
1251
- }),
1252
- definePartyEvent({
1253
- name: PARTY_EVENTS.ARCHIVED,
1254
- description: "A party was archived.",
1255
- zodSchema: lifecycleSchema
1256
- }),
1257
- definePartyEvent({
1258
- name: PARTY_EVENTS.RESTORED,
1259
- description: "An archived party was restored to active.",
1260
- zodSchema: lifecycleSchema
1261
- }),
1262
- definePartyEvent({
1263
- name: PARTY_EVENTS.MERGED,
1264
- description: "A duplicate party was merged into a survivor; the source becomes a redirect.",
1265
- zodSchema: mergedSchema
1881
+ name: PARTY_EVENTS.RELATIONSHIP_CHANGED,
1882
+ description: "A dated relationship of the party (shareholder, director, lessor, …) was added or ended.",
1883
+ zodSchema: relationshipChangedSchema
1266
1884
  })
1267
1885
  ];
1268
1886
  //#endregion
1269
- export { ADDRESS_TYPES, CONTACT_POINT_TYPES, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS, PARTY_MODEL_NAMES, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PENDING_EVENTS, PartyError, PartyEventDispatcher, PartyMergeError, PartyModelCollisionError, PartyNotFoundError, PartyRepository, PartyResolveConflictError, PartyValidationError, PartyVersionConflictError, buildContactPoints, buildPartySchema, contactPointKey, createEvent, createParty, createParty as default, createPartyModels, dedupe, deriveIdentityKeys, flushPending, normalizeEmail, normalizePhone, partyEventDefinitions, pickPrimary, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };
1887
+ export { ADDRESS_TYPES, CONSENT_STATUSES, CONTACT_POINT_TYPES, InProcessPartyBus, PARTY_EVENTS, PARTY_KINDS, PARTY_ROLES, PARTY_STATE_MACHINE, PARTY_STATUSES, PARTY_SUBJECT_MODEL, PENDING_EVENTS, PartyError, PartyEventDispatcher, PartyMergeError, PartyNotFoundError, PartyPhoneFormatError, PartyRelationshipError, PartyRepository, PartyResolveConflictError, PartyValidationError, PartyVersionConflictError, RELATIONSHIP_KINDS, buildContactPoints, buildPartySchema, contactPointKey, createEvent, dedupe, defineParty, definePartyModels, deriveIdentityKeys, flushPending, isPartySubject, normalizeEmail, partyEventDefinitions, partySubject, pickPrimary, reconcileConsent, repoOptionsFromCtx, resolveDisplayName, taxIdentityKey, tenantFieldDef, unionContacts, unionRefs, unionStrings };