@pdtf/schemas 3.6.0-dev.19 → 3.6.0-dev.21

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.
@@ -0,0 +1,1341 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * V4 Schema Generator — Dynamic Entity Decomposition
4
+ *
5
+ * Reads the canonical V3 combined.json and produces V4 entity schemas
6
+ * in src/schemas/v4/. This replaces the old static-divergence approach
7
+ * (branch 263-*) with a generator that always derives V4 from the latest
8
+ * V3 state.
9
+ *
10
+ * Entity model:
11
+ * Property — physical facts about the property (address, construction, etc.)
12
+ * Title — register extract + flattened tenure/legal-interest details
13
+ * Transaction — metadata, milestones, chain, saleContext (residual ownership facts)
14
+ * Person — natural person extracted from participants
15
+ * Organisation — legal entity (company, firm, etc.)
16
+ *
17
+ * Relationship entities (credential-facing projections, not part of the
18
+ * reversible V3 mapping):
19
+ * Representation — professional representation (conveyancer↔party)
20
+ * SellerCapacity — legal capacity of a seller against a title (derived from
21
+ * the V3 participants "Seller" oneOf branch)
22
+ * Offer — buyer offer against a transaction
23
+ *
24
+ * The run also emits src/schemas/v4/mapping.json — the machine-readable V3⇄V4
25
+ * pointer mapping consumed by src/utils/v4.js. See
26
+ * docs/v4-mapping-and-recomposition.md.
27
+ */
28
+
29
+ const fs = require("fs");
30
+ const path = require("path");
31
+ const crypto = require("crypto");
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Paths
35
+ // ---------------------------------------------------------------------------
36
+ const COMBINED_PATH = path.resolve(__dirname, "../schemas/v3/combined.json");
37
+ const V4_DIR = path.resolve(__dirname, "../schemas/v4");
38
+ const PKG_PATH = path.resolve(__dirname, "../../package.json");
39
+
40
+ const combinedRaw = fs.readFileSync(COMBINED_PATH, "utf8");
41
+ const combined = JSON.parse(combinedRaw);
42
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, "utf8"));
43
+ const pp = combined.properties.propertyPack.properties;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Provenance — records exactly which V3 input produced this V4 output, so
47
+ // consumers can pin.
48
+ //
49
+ // The content hash is deliberately the ONLY identifier. A commit sha would tie
50
+ // generated output to git history: it could never be correct in the same commit
51
+ // that changes combined.json, and it would churn on every rebase or cherry-pick
52
+ // without the schema content differing at all. To find the commit for a hash,
53
+ // search the history for it.
54
+ // ---------------------------------------------------------------------------
55
+ const combinedSha256 = crypto
56
+ .createHash("sha256")
57
+ .update(combinedRaw)
58
+ .digest("hex");
59
+
60
+ const SOURCE = {
61
+ packageName: pkg.name,
62
+ packageVersion: pkg.version,
63
+ v3SchemaId: combined.$id,
64
+ combinedPath: "src/schemas/v3/combined.json",
65
+ combinedSha256,
66
+ };
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Decomposition plan constants
70
+ //
71
+ // These are the single definition of what each entity takes from V3. Both the
72
+ // schema generators below AND the generated mapping manifest read them, so the
73
+ // manifest cannot drift from the schemas. `verifyCoverage()` additionally
74
+ // asserts at generation time that every V3 key is accounted for by exactly one
75
+ // rule — adding a field to V3 with no home fails the build rather than silently
76
+ // disappearing from the V4 round trip.
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** propertyPack keys that do NOT belong to Property */
80
+ const PROPERTY_EXCLUDE = ["titlesToBeSold", "ownership", "legalOwners"];
81
+
82
+ /** top-level keys that do NOT belong to Transaction */
83
+ const TRANSACTION_EXCLUDE = ["propertyPack", "participants"];
84
+
85
+ /**
86
+ * participants item keys that describe a *relationship*, not the person.
87
+ * They are transaction-scoped, so they are carried on Transaction.participants[]
88
+ * rather than on Person — see the "transaction.participants" mapping rule.
89
+ */
90
+ const PERSON_RELATIONSHIP_FIELDS = [
91
+ "role",
92
+ "sellersCapacity",
93
+ "organisation",
94
+ "organisationReference",
95
+ // Identity and representation edges are scoped to this transaction: the same
96
+ // person may act in a different capacity, or for different parties, in another.
97
+ "participantId",
98
+ "actingFor",
99
+ ];
100
+
101
+ /**
102
+ * Of those, the ones Transaction.participants[] actually keeps.
103
+ *
104
+ * did is the party's own identifier and becomes Person.id directly — V4 mints
105
+ * one only where V3 supplies none. participantId is the transaction-local
106
+ * fallback that actingFor may reference instead; organisation and
107
+ * organisationReference say which firm a party is at, which is true of them
108
+ * whether or not any relationship exists and is not revoked when one ends.
109
+ * Everything else moved to the credentials, so that revoking a credential
110
+ * actually removes the relationship it asserts rather than leaving a second
111
+ * copy behind on the transaction.
112
+ */
113
+ const PARTICIPANT_ROSTER_FIELDS = [
114
+ "did",
115
+ "participantId",
116
+ "organisation",
117
+ "organisationReference",
118
+ ];
119
+
120
+ /**
121
+ * Entities that participate in the derived, reversible V3 mapping. Everything
122
+ * else in src/schemas/v4 (Organisation, Representation, SellerCapacity, Offer)
123
+ * is a credential-facing projection: useful for issuing VCs, but NOT required
124
+ * to reconstruct a V3 instance, and therefore not part of the round trip.
125
+ */
126
+ const DERIVED_ENTITIES = ["Property", "Title", "Transaction", "Person"];
127
+
128
+ /**
129
+ * Field names that identify an item within its array, in precedence order.
130
+ *
131
+ * A V3 JSON Pointer addresses array items by position — /propertyPack/documents/3
132
+ * — which is only meaningful against one producer's ordering. Two parties
133
+ * editing the same array independently will disagree about index 3. Where an
134
+ * array's items carry one of these fields, the manifest publishes it so a
135
+ * consumer can address the item by identity instead.
136
+ *
137
+ * This is a list of FIELD NAMES, not paths, so it does not need maintaining as
138
+ * V3 grows: a new array whose items carry `documentId` is keyed automatically.
139
+ * Arrays matching none of these are published too, under `unkeyedArrays`, so
140
+ * nothing is silently position-addressed.
141
+ *
142
+ * These are all CANONICAL keys: they live in the shared payload, so any party
143
+ * holding the transaction can resolve them. See EXTERNAL_IDS_CANDIDATE for the
144
+ * vendor-scoped fallback.
145
+ */
146
+ const ARRAY_KEY_FIELDS = [
147
+ // Added to V3 for exactly this purpose
148
+ "participantId",
149
+ "documentId",
150
+ "searchId",
151
+ "surveyId",
152
+ "mediaId",
153
+ "contractId",
154
+ "signatureId",
155
+ // Pre-existing identifiers, usable as-is
156
+ "titleNumber",
157
+ "transactionId",
158
+ "photoId",
159
+ "certificateId",
160
+ "valuationId",
161
+ "lmkKey",
162
+ "improvementId",
163
+ "hmlrReference",
164
+ "partyNumber",
165
+ "uprn",
166
+ "refNumber",
167
+ "reportName",
168
+ "subCategory",
169
+ "itemName",
170
+ "roomName",
171
+ "dataLabel",
172
+ "day",
173
+ ];
174
+
175
+ /**
176
+ * The relationship credentials.
177
+ *
178
+ * These are the linking entities that EMBODY a party's role: a Representation
179
+ * says a conveyancer acts for a seller, a SellerCapacity says a person sells in
180
+ * a given capacity, an Offer says a person is the buyer. The role is the
181
+ * relationship, not a label on the participant — so these are what a VC issuer
182
+ * signs and a holder presents, one relationship at a time.
183
+ *
184
+ * They are built FROM the derived entities rather than from V3 directly and add
185
+ * no information, so `recompose` neither needs nor accepts them. V3's own
186
+ * `role` field is retained on Transaction.participants[] because it is V3 data
187
+ * that must round trip — the credentials embody it, they do not replace it.
188
+ */
189
+ const CREDENTIAL_ENTITIES = [
190
+ "Representation",
191
+ "SellerCapacity",
192
+ "Offer",
193
+ "Gift",
194
+ "TransactionRole",
195
+ ];
196
+
197
+ /**
198
+ * Role is not stored on the participant. For three credential types it is
199
+ * implied 1:1 by the type itself — V3 puts sellersCapacity only on the Seller
200
+ * branch, giftDetails only on Gift Donor, and offerId on Buyer (and Gift Donor,
201
+ * whose offer link lives on the Gift credential instead, keeping this 1:1).
202
+ * Representation and TransactionRole each span several roles, so they carry
203
+ * role as their own discriminator: "what kind of representation is this" is a
204
+ * property of the representation, not a duplicated participant attribute.
205
+ */
206
+ const ROLE_IMPLIED_BY_CREDENTIAL = {
207
+ SellerCapacity: "Seller",
208
+ Offer: "Buyer",
209
+ Gift: "Gift Donor",
210
+ };
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Helpers
214
+ // ---------------------------------------------------------------------------
215
+ const URN_PATTERN =
216
+ "^urn:[a-z0-9][a-z0-9-]{0,31}:[a-zA-Z0-9._~:/?#\\[\\]@!$&'()*+,;=-]+$";
217
+ /**
218
+ * W3C DID Core ABNF:
219
+ *
220
+ * did = "did:" method-name ":" method-specific-id
221
+ * method-name = 1*( %x61-7A / DIGIT )
222
+ * method-specific-id = *( *idchar ":" ) 1*idchar
223
+ * idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded
224
+ *
225
+ * The method-specific id may therefore contain colons, which matters: a did:web
226
+ * encodes its path that way, e.g.
227
+ * did:web:moverly.com:transactions:LnRdGiBUkLbnuNJ89p4CEj, and a port is
228
+ * percent-encoded as did:web:example.com%3A3000:... . An earlier pattern here
229
+ * disallowed both and so rejected every did:web carrying a path.
230
+ */
231
+ const DID_IDCHAR = "(?:[a-zA-Z0-9._-]|%[0-9A-Fa-f]{2})";
232
+ const DID_PATTERN = `^did:[a-z0-9]+:(?:${DID_IDCHAR}*:)*${DID_IDCHAR}+$`;
233
+
234
+ const urnRef = (title, description) => ({
235
+ title,
236
+ description,
237
+ type: "string",
238
+ pattern: URN_PATTERN,
239
+ });
240
+
241
+ const didRef = (title, description) => ({
242
+ title,
243
+ description,
244
+ type: "string",
245
+ pattern: DID_PATTERN,
246
+ });
247
+
248
+ /**
249
+ * All data-bearing properties of a V3 node, including those contributed by its
250
+ * `oneOf` discriminator branches.
251
+ *
252
+ * V3 uses `discriminator` + `oneOf` to attach conditional field groups — e.g.
253
+ * `leaseholdInformation` only exists on the branch where `ownershipType` is
254
+ * "Leasehold", and `sellersCapacity` only where `role` is "Seller". Reading
255
+ * `node.properties` alone silently drops every one of them, which also breaks
256
+ * the round trip. Branch field sets are disjoint in V3, so first-branch-wins is
257
+ * safe; the discriminator key itself is kept from the base, where its enum is
258
+ * complete.
259
+ */
260
+ const mergedProperties = (node) => {
261
+ const discriminator = node?.discriminator?.propertyName;
262
+ const out = { ...(node?.properties || {}) };
263
+ for (const branch of node?.oneOf || []) {
264
+ for (const [key, subschema] of Object.entries(branch.properties || {})) {
265
+ if (key === discriminator) continue;
266
+ if (!(key in out)) out[key] = subschema;
267
+ }
268
+ }
269
+ return out;
270
+ };
271
+
272
+ /**
273
+ * Find the participants `oneOf` branch whose discriminator (`role`) admits the
274
+ * given role, e.g. "Seller". Used instead of a hard-coded branch index so the
275
+ * generator survives reordering of the V3 oneOf.
276
+ */
277
+ const participantBranch = (role) =>
278
+ (combined.properties.participants.items.oneOf || []).find((b) =>
279
+ (b.properties?.role?.enum || []).includes(role)
280
+ );
281
+
282
+ /** Strip overlay-specific metadata keys (e.g. baspi4Ref, ta6Required) */
283
+ const cleanOverlayMeta = (obj) => {
284
+ if (Array.isArray(obj)) return obj.map(cleanOverlayMeta);
285
+ if (obj && typeof obj === "object") {
286
+ const out = {};
287
+ for (const [k, v] of Object.entries(obj)) {
288
+ if (k.endsWith("Ref") || k.endsWith("Required")) continue;
289
+ out[k] = cleanOverlayMeta(v);
290
+ }
291
+ return out;
292
+ }
293
+ return obj;
294
+ };
295
+
296
+ const makeSchema = (id, title, description, properties, required = []) => ({
297
+ $schema: "http://json-schema.org/draft-07/schema#",
298
+ $id: `https://trust.propdata.org.uk/schemas/v4/entities/${id}.json`,
299
+ type: "object",
300
+ title,
301
+ description,
302
+ "x-pdtf-source": SOURCE,
303
+ properties: cleanOverlayMeta(properties),
304
+ ...(required.length > 0 && { required }),
305
+ });
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Property — everything in propertyPack except ownership & titlesToBeSold
309
+ // ---------------------------------------------------------------------------
310
+ const generateProperty = () => {
311
+ const physicalProps = Object.fromEntries(
312
+ Object.entries(pp).filter(([k]) => !PROPERTY_EXCLUDE.includes(k))
313
+ );
314
+
315
+ physicalProps.id = urnRef(
316
+ "Property URN",
317
+ "Uniform Resource Name identifier for the property"
318
+ );
319
+
320
+ const required = (
321
+ combined.properties.propertyPack.required || []
322
+ ).filter((r) => !PROPERTY_EXCLUDE.includes(r));
323
+
324
+ return makeSchema(
325
+ "property",
326
+ "Property Entity",
327
+ "Physical and environmental facts about a property — address, construction, fixtures, services, searches, documents. Excludes ownership, titles, and participants.",
328
+ physicalProps,
329
+ required
330
+ );
331
+ };
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Title — titlesToBeSold items + flattened tenure from ownershipsToBeTransferred
335
+ // ---------------------------------------------------------------------------
336
+ const generateTitle = () => {
337
+ const titleItemProps = { ...mergedProperties(pp.titlesToBeSold.items) };
338
+ const ownershipItems = pp.ownership.properties.ownershipsToBeTransferred?.items;
339
+
340
+ // Merge ownership-item fields (tenure type, leasehold details, etc.). The
341
+ // tenure detail — leaseholdInformation, managedFreeholdOrCommonholdInformation,
342
+ // estateRentcharges, wholeFreeholdForSale, otherOwnershipDetails — lives on the
343
+ // ownershipType oneOf branches, so mergedProperties is required here.
344
+ Object.assign(titleItemProps, cleanOverlayMeta(mergedProperties(ownershipItems)));
345
+
346
+ // `titleNumber` is the one key present on BOTH sources. It is the same fact
347
+ // in both and is what correlates the two arrays (their indices need not
348
+ // align), so it is set explicitly here rather than being decided by merge
349
+ // order. See `collisions` in the generated mapping manifest.
350
+ titleItemProps.titleNumber = {
351
+ title: "Title Number",
352
+ description:
353
+ "HM Land Registry title number. In V3 this appears in both propertyPack.titlesToBeSold and propertyPack.ownership.ownershipsToBeTransferred; it is the key correlating the two.",
354
+ type: "string",
355
+ };
356
+
357
+ titleItemProps.id = urnRef(
358
+ "Title URN",
359
+ "Uniform Resource Name identifier for the title"
360
+ );
361
+
362
+ const required = [
363
+ ...new Set([
364
+ ...(pp.titlesToBeSold.items.required || []),
365
+ ...(ownershipItems?.required || []),
366
+ ]),
367
+ ];
368
+
369
+ return makeSchema(
370
+ "title",
371
+ "Title Entity",
372
+ "HM Land Registry title — register extract, tenure type, and legal interest details for a single title number.",
373
+ titleItemProps,
374
+ required
375
+ );
376
+ };
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Transaction — top-level metadata + saleContext (residual ownership facts)
380
+ // ---------------------------------------------------------------------------
381
+ const generateTransaction = () => {
382
+ const topLevel = Object.fromEntries(
383
+ Object.entries(combined.properties).filter(
384
+ ([k]) => k !== "$schema" && !TRANSACTION_EXCLUDE.includes(k)
385
+ )
386
+ );
387
+
388
+ topLevel.id = didRef(
389
+ "Transaction DID",
390
+ "Decentralised Identifier for the transaction"
391
+ );
392
+
393
+ topLevel.property = urnRef(
394
+ "Property Reference",
395
+ "URN referencing the Property entity associated with this transaction"
396
+ );
397
+
398
+ topLevel.titlesToBeSold = {
399
+ title: "Titles to be Sold",
400
+ description:
401
+ "Array of URNs referencing Title entities to be sold in this transaction",
402
+ type: "array",
403
+ minItems: 1,
404
+ items: urnRef("Title URN", "URN reference to a Title entity"),
405
+ };
406
+
407
+ // V3 `participants` is an ordered array; V4 Person/Organisation entities are
408
+ // id-keyed and therefore unordered. This list is where the original array
409
+ // order lives, mirroring the titlesToBeSold pattern above, so recompose can
410
+ // rebuild participants[] deterministically without index fields leaking into
411
+ // the entity documents themselves.
412
+ topLevel.participants = {
413
+ title: "Participants",
414
+ description:
415
+ "Ordered roster of the parties to this transaction. Each entry references a Person or Organisation entity and carries only its transaction-scoped identity and firm; role and every relationship live on the credentials, so that revoking a credential removes what it asserts. The array order is the V3 participants order and is authoritative for recomposition.",
416
+ type: "array",
417
+ items: {
418
+ type: "object",
419
+ title: "Transaction Participant",
420
+ properties: {
421
+ participant: didRef(
422
+ "Participant DID",
423
+ "DID reference to the Person or Organisation entity"
424
+ ),
425
+ // Only the roster fields. Role and every relationship field live on the
426
+ // credentials, so that revoking one removes what it asserts.
427
+ ...cleanOverlayMeta(
428
+ Object.fromEntries(
429
+ Object.entries(mergedProperties(combined.properties.participants.items))
430
+ .filter(([k]) => PARTICIPANT_ROSTER_FIELDS.includes(k))
431
+ )
432
+ ),
433
+ },
434
+ required: ["participant"],
435
+ },
436
+ };
437
+
438
+ // Build saleContext from ownership fields (minus ownershipsToBeTransferred)
439
+ const ownershipFields = pp.ownership?.properties || {};
440
+ const saleContextFields = Object.fromEntries(
441
+ Object.entries(ownershipFields).filter(
442
+ ([k]) => k !== "ownershipsToBeTransferred"
443
+ )
444
+ );
445
+
446
+ // propertyPack.legalOwners is excluded from Property but has no entity of its
447
+ // own: it is an unidentified name list with no link to participants, so it
448
+ // cannot be correlated to Person/Organisation entities. It is a sale-level
449
+ // ownership fact of the same family as numberOfSellers / isLimitedCompanySale,
450
+ // so it is carried here to keep the round trip lossless.
451
+ if (pp.legalOwners) {
452
+ saleContextFields.legalOwners = pp.legalOwners;
453
+ }
454
+
455
+ topLevel.saleContext = {
456
+ title: "Sale Context",
457
+ description:
458
+ "Residual ownership/sale facts that apply at the transaction level (e.g. outstanding mortgage, Help to Buy equity loan status).",
459
+ type: "object",
460
+ properties: cleanOverlayMeta(saleContextFields),
461
+ };
462
+
463
+ const required = (combined.required || []).filter(
464
+ (r) => !["propertyPack", "participants"].includes(r)
465
+ );
466
+
467
+ return makeSchema(
468
+ "transaction",
469
+ "Transaction Entity",
470
+ "Transaction-level metadata — status, milestones, chain, contracts, offers, enquiries, and sale context.",
471
+ topLevel,
472
+ required
473
+ );
474
+ };
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Person — extracted from participants (minus role/sellersCapacity)
478
+ // ---------------------------------------------------------------------------
479
+ const generatePerson = () => {
480
+ const participantSchema = combined.properties.participants.items;
481
+ // NB `sellersCapacity` is not on items.properties at all — it lives on the
482
+ // "Seller" oneOf branch, so filtering it out here is a no-op; it is carried
483
+ // on Transaction.participants[] and by the SellerCapacity entity.
484
+ const personFields = Object.fromEntries(
485
+ Object.entries(participantSchema.properties).filter(
486
+ ([k]) => !PERSON_RELATIONSHIP_FIELDS.includes(k)
487
+ )
488
+ );
489
+
490
+ personFields.id = didRef(
491
+ "Person DID",
492
+ "Decentralised Identifier for the person"
493
+ );
494
+
495
+ const required = (participantSchema.required || []).filter(
496
+ (r) => !PERSON_RELATIONSHIP_FIELDS.includes(r)
497
+ );
498
+
499
+ return makeSchema(
500
+ "person",
501
+ "Person Entity",
502
+ "A natural person participating in a property transaction — name, contact details, address, verification status.",
503
+ personFields,
504
+ required
505
+ );
506
+ };
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // Organisation — hand-authored but generated here for completeness
510
+ // ---------------------------------------------------------------------------
511
+ const generateOrganisation = () => {
512
+ const properties = {
513
+ id: didRef(
514
+ "Organisation DID",
515
+ "Decentralised Identifier for the organisation"
516
+ ),
517
+ name: {
518
+ title: "Organisation name",
519
+ description: "The legal / registered name of the organisation",
520
+ type: "string",
521
+ minLength: 1,
522
+ },
523
+ tradingName: {
524
+ title: "Trading name",
525
+ description:
526
+ "Trading name, if different from the registered legal name",
527
+ type: "string",
528
+ },
529
+ organisationType: {
530
+ title: "Organisation type",
531
+ type: "string",
532
+ enum: [
533
+ "Limited Company",
534
+ "Limited Liability Partnership",
535
+ "Partnership",
536
+ "Sole Trader",
537
+ "Public Limited Company",
538
+ "Charity",
539
+ "Public Body",
540
+ "Trust",
541
+ "Other",
542
+ ],
543
+ },
544
+ companiesHouseNumber: {
545
+ title: "Companies House registration number",
546
+ description:
547
+ "UK Companies House company registration number (or equivalent)",
548
+ type: "string",
549
+ },
550
+ vatNumber: {
551
+ title: "VAT registration number",
552
+ type: "string",
553
+ },
554
+ regulatoryIds: {
555
+ title: "Regulatory identifiers",
556
+ description:
557
+ "Identifiers for the professional / regulatory bodies the organisation is registered with",
558
+ type: "object",
559
+ properties: {
560
+ sraNumber: {
561
+ title: "Solicitors Regulation Authority (SRA) number",
562
+ type: "string",
563
+ },
564
+ clcNumber: {
565
+ title: "Council for Licensed Conveyancers (CLC) number",
566
+ type: "string",
567
+ },
568
+ ricsNumber: {
569
+ title: "Royal Institution of Chartered Surveyors (RICS) number",
570
+ type: "string",
571
+ },
572
+ fcaNumber: {
573
+ title: "Financial Conduct Authority (FCA) reference number",
574
+ type: "string",
575
+ },
576
+ tpoNumber: {
577
+ title: "The Property Ombudsman (TPO) membership number",
578
+ type: "string",
579
+ },
580
+ icoNumber: {
581
+ title: "Information Commissioner's Office (ICO) registration number",
582
+ type: "string",
583
+ },
584
+ },
585
+ },
586
+ registeredAddress: {
587
+ title: "Registered address",
588
+ type: "object",
589
+ properties: {
590
+ buildingNumber: { title: "Building number", type: "string" },
591
+ buildingName: { title: "Building name", type: "string" },
592
+ subBuilding: { title: "Sub building name", type: "string" },
593
+ street: { title: "Street", type: "string" },
594
+ line1: { title: "Address 1", type: "string" },
595
+ line2: { title: "Address 2", type: "string" },
596
+ line3: { title: "Address 3", type: "string" },
597
+ town: { title: "Town", type: "string" },
598
+ postcode: { title: "Postcode", type: "string" },
599
+ homeNation: {
600
+ title: "Home nation",
601
+ type: "string",
602
+ enum: ["England", "Wales", "Scotland", "Northern Ireland"],
603
+ },
604
+ countryCode: {
605
+ title: "Country code",
606
+ description: "ISO 3166-1 alpha-3 country code",
607
+ type: "string",
608
+ minLength: 3,
609
+ maxLength: 3,
610
+ },
611
+ },
612
+ },
613
+ phone: { title: "Primary phone number", type: "string" },
614
+ email: { title: "Primary email address", type: "string", format: "email" },
615
+ website: { title: "Website", type: "string", format: "uri" },
616
+ externalIds: { type: "object" },
617
+ };
618
+
619
+ return makeSchema(
620
+ "organisation",
621
+ "Organisation Entity",
622
+ "A legal entity (company, firm, partnership) participating in a property transaction.",
623
+ properties,
624
+ ["name"]
625
+ );
626
+ };
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // Relationship entities — these are hand-authored, not derived from V3
630
+ // ---------------------------------------------------------------------------
631
+ const generateRepresentation = () => {
632
+ // Derived. A Representation exists wherever a V3 participant declares
633
+ // `actingFor`, and its role is that participant's own role carried verbatim —
634
+ // so the enum cannot drift from V3, and no hand-maintained list of "which
635
+ // roles count as representation" is needed.
636
+ const participantProps = combined.properties.participants.items.properties;
637
+
638
+ return makeSchema(
639
+ "representation",
640
+ "Representation Entity",
641
+ "A professional representation relationship — one party instructed by another, e.g. a conveyancer acting for a seller. One entity per (representative, represented party) pair: a conveyancer instructed jointly by two sellers yields two, and sellers who instruct separate conveyancers yield one each.",
642
+ {
643
+ id: urnRef(
644
+ "Representation URN",
645
+ "Uniform Resource Name identifier for the representation"
646
+ ),
647
+ representative: didRef(
648
+ "Representative Reference",
649
+ "DID referencing the Person or Organisation who is instructed"
650
+ ),
651
+ representedParty: didRef(
652
+ "Represented Party Reference",
653
+ "DID referencing the Person or Organisation who instructed them"
654
+ ),
655
+ role: {
656
+ ...cleanOverlayMeta(participantProps.role),
657
+ description:
658
+ "The kind of representation this is — a property of the representation, not a duplicated participant attribute. The representative's firm is on the transaction roster.",
659
+ },
660
+ transaction: didRef(
661
+ "Transaction Reference",
662
+ "DID referencing the Transaction entity"
663
+ ),
664
+ },
665
+ ["representative", "representedParty", "role", "transaction"]
666
+ );
667
+ };
668
+
669
+ const generateSellerCapacity = () => {
670
+ // Derived, not hand-authored. The seller-capacity fields live on the V3
671
+ // participants "Seller" oneOf branch, which the Person generator does not
672
+ // read, so they had no home in V4 at all. Deriving them keeps the enum in
673
+ // step with V3 `capacity` — the previous hand-authored `capacityType` offered
674
+ // four values against V3's nine.
675
+ const sellerBranch = participantBranch("Seller") || { properties: {} };
676
+ const branchFields = Object.fromEntries(
677
+ Object.entries(sellerBranch.properties || {}).filter(([k]) => k !== "role")
678
+ );
679
+
680
+ return makeSchema(
681
+ "sellerCapacity",
682
+ "Seller Capacity Entity",
683
+ "The legal capacity in which a seller is selling a given title. Derived from the V3 participants 'Seller' branch (sellersCapacity, dateBecameOwnerOrAuthority).",
684
+ {
685
+ id: urnRef(
686
+ "Seller Capacity URN",
687
+ "Uniform Resource Name identifier for the seller capacity"
688
+ ),
689
+ seller: didRef(
690
+ "Seller Reference",
691
+ "DID referencing the Person or Organisation acting as seller"
692
+ ),
693
+ title: urnRef(
694
+ "Title Reference",
695
+ "URN referencing the Title entity being sold"
696
+ ),
697
+ ...branchFields,
698
+ transaction: didRef(
699
+ "Transaction Reference",
700
+ "DID referencing the Transaction entity"
701
+ ),
702
+ },
703
+ ["seller", "transaction"]
704
+ );
705
+ };
706
+
707
+ const generateTransactionRole = () => {
708
+ // The catch-all relationship credential. Representation, SellerCapacity and
709
+ // Offer each carry a specific relationship; this one asserts only that a
710
+ // party takes a given role in a transaction, which is all V3 records for a
711
+ // Lender, Landlord, Tenant, Gift Donor or Platform Support. It is also what
712
+ // covers a party whose specific relationship is not yet established — a
713
+ // conveyancer instructed but with no actingFor recorded, say — so that every
714
+ // participant carrying a role has exactly one credential embodying it.
715
+ const participantProps = combined.properties.participants.items.properties;
716
+
717
+ return makeSchema(
718
+ "transactionRole",
719
+ "Transaction Role Entity",
720
+ "A party's role in a transaction, where no more specific relationship credential applies. Asserts participation in a role, not a relationship to another named party.",
721
+ {
722
+ id: urnRef(
723
+ "Transaction Role URN",
724
+ "Uniform Resource Name identifier for the transaction role"
725
+ ),
726
+ participant: didRef(
727
+ "Participant Reference",
728
+ "DID referencing the Person or Organisation holding the role"
729
+ ),
730
+ role: cleanOverlayMeta(participantProps.role),
731
+ transaction: didRef(
732
+ "Transaction Reference",
733
+ "DID referencing the Transaction entity"
734
+ ),
735
+ },
736
+ ["participant", "role", "transaction"]
737
+ );
738
+ };
739
+
740
+ const generateGift = () => {
741
+ // A gift is a linking relationship with legal consequence: whether the money
742
+ // is truly a gift rather than a loan, and whether the donor acquires an
743
+ // interest in or occupation of the property, are what a conveyancer must
744
+ // resolve before reporting to a lender. Separately revocable, and the role
745
+ // "Gift Donor" is implied by the credential's existence.
746
+ const giftBranch =
747
+ (combined.properties.participants.items.oneOf || []).find(
748
+ (b) => b.properties?.giftDetails
749
+ ) || { properties: {} };
750
+
751
+ return makeSchema(
752
+ "gift",
753
+ "Gift Entity",
754
+ "A gift of funds towards a purchase, made by a gift donor. Implies the Gift Donor role.",
755
+ {
756
+ id: urnRef("Gift URN", "Uniform Resource Name identifier for the gift"),
757
+ donor: didRef(
758
+ "Donor Reference",
759
+ "DID referencing the Person or Organisation making the gift"
760
+ ),
761
+ offerId: {
762
+ ...cleanOverlayMeta(giftBranch.properties.offerId || { type: "string" }),
763
+ title: "Offer id",
764
+ description:
765
+ "The offer this gift is contributing towards, as keyed in Transaction.offers. Held here rather than on Offer so that an Offer credential always implies the Buyer role.",
766
+ },
767
+ giftDetails: cleanOverlayMeta(giftBranch.properties.giftDetails || { type: "object" }),
768
+ transaction: didRef(
769
+ "Transaction Reference",
770
+ "DID referencing the Transaction entity"
771
+ ),
772
+ },
773
+ ["donor", "transaction"]
774
+ );
775
+ };
776
+
777
+ const generateOffer = () => {
778
+ // Pull offer fields from V3 offers patternProperties where available
779
+ const v3OfferSchema =
780
+ combined.properties.offers?.patternProperties?.[".*"] ||
781
+ combined.properties.offers?.patternProperties?.[
782
+ Object.keys(combined.properties.offers?.patternProperties || {})[0]
783
+ ];
784
+
785
+ // The V3 offers object is keyed by an offer id, and participants on the
786
+ // "Buyer" branch reference it via `offerId`. Carrying that key here is what
787
+ // keeps the participant-to-offer link reconstructible.
788
+ const buyerBranch = participantBranch("Buyer");
789
+
790
+ const offerProps = {
791
+ id: urnRef("Offer URN", "Uniform Resource Name identifier for the offer"),
792
+ offerId: {
793
+ ...cleanOverlayMeta(buyerBranch?.properties?.offerId || { type: "string" }),
794
+ title: "Offer id",
795
+ description:
796
+ "The V3 offers object key for this offer, as referenced by participants[].offerId",
797
+ },
798
+ buyer: didRef(
799
+ "Buyer Reference",
800
+ "DID referencing the Person or Organisation making the offer"
801
+ ),
802
+ transaction: didRef(
803
+ "Transaction Reference",
804
+ "DID referencing the Transaction entity"
805
+ ),
806
+ };
807
+
808
+ // Merge fields from V3 offer schema if present
809
+ if (v3OfferSchema?.properties) {
810
+ const { externalIds, ...offerFields } = cleanOverlayMeta(
811
+ v3OfferSchema.properties
812
+ );
813
+ Object.assign(offerProps, offerFields);
814
+ } else {
815
+ // Fallback minimal fields
816
+ offerProps.amount = {
817
+ title: "Offer Amount",
818
+ description: "The monetary amount of the offer",
819
+ type: "number",
820
+ minimum: 0,
821
+ };
822
+ offerProps.status = {
823
+ title: "Offer Status",
824
+ description: "Current status of the offer",
825
+ type: "string",
826
+ enum: ["Pending", "Accepted", "Rejected", "Withdrawn"],
827
+ };
828
+ }
829
+
830
+ return makeSchema(
831
+ "offer",
832
+ "Offer Entity",
833
+ "An offer made by a prospective buyer in a property transaction.",
834
+ offerProps,
835
+ // V3 requires nothing of an offer, so requiring amount and status here
836
+ // would make a valid V3 instance produce an invalid Offer credential.
837
+ // Only the references the credential cannot exist without are required.
838
+ ["buyer", "transaction"]
839
+ );
840
+ };
841
+
842
+ // ---------------------------------------------------------------------------
843
+ // Main — generate all schemas and write to src/schemas/v4/
844
+ // ---------------------------------------------------------------------------
845
+ const generators = {
846
+ Property: generateProperty,
847
+ Title: generateTitle,
848
+ Transaction: generateTransaction,
849
+ Person: generatePerson,
850
+ Organisation: generateOrganisation,
851
+ Representation: generateRepresentation,
852
+ TransactionRole: generateTransactionRole,
853
+ Gift: generateGift,
854
+ SellerCapacity: generateSellerCapacity,
855
+ Offer: generateOffer,
856
+ };
857
+
858
+ if (!fs.existsSync(V4_DIR)) {
859
+ fs.mkdirSync(V4_DIR, { recursive: true });
860
+ }
861
+
862
+ const schemas = {};
863
+ for (const [name, generate] of Object.entries(generators)) {
864
+ schemas[name] = generate();
865
+ }
866
+
867
+ // ---------------------------------------------------------------------------
868
+ // Mapping manifest
869
+ //
870
+ // Emitted from the same run, from the same constants the generators used, so
871
+ // it cannot drift from the schemas. Rules are *prefix rewrites*: a V3 JSON
872
+ // Pointer (as used by PDTF 1.x verified claims) is resolved by longest-prefix
873
+ // match against `v3Pointer`, giving the target entity and the pointer within
874
+ // it. This covers pointers far deeper than anything enumerated here, which is
875
+ // what claims actually carry.
876
+ // ---------------------------------------------------------------------------
877
+
878
+ const participantItems = combined.properties.participants.items;
879
+ const participantBranchKeys = Object.keys(
880
+ mergedProperties(participantItems)
881
+ ).filter((k) => !(k in participantItems.properties));
882
+ const participantContextKeys = [
883
+ ...PERSON_RELATIONSHIP_FIELDS,
884
+ ...participantBranchKeys.filter((k) => !PERSON_RELATIONSHIP_FIELDS.includes(k)),
885
+ ];
886
+ const personKeys = Object.keys(participantItems.properties).filter(
887
+ (k) => !PERSON_RELATIONSHIP_FIELDS.includes(k)
888
+ );
889
+
890
+ const titlesKeys = Object.keys(mergedProperties(pp.titlesToBeSold.items));
891
+ const ownershipItemKeys = Object.keys(
892
+ mergedProperties(pp.ownership.properties.ownershipsToBeTransferred?.items)
893
+ );
894
+ const titleCollisionKeys = titlesKeys.filter((k) =>
895
+ ownershipItemKeys.includes(k)
896
+ );
897
+
898
+ /**
899
+ * Vendor-scoped fallback, offered for any array whose items carry `externalIds`.
900
+ *
901
+ * `externalIds` answers "which of MY records is this?", not "which item are we
902
+ * both talking about?" — it is a per-source back-reference, so it is only
903
+ * resolvable by a party who knows that source's namespace, and an item written
904
+ * by someone else carries no entry for it at all. That makes it a usable last
905
+ * resort when the canonical key is absent (every V3 identifier is optional) but
906
+ * a poor primary key, which is why it always ranks below the canonical one and
907
+ * is labelled `vendor` so a consumer cannot mistake its scope.
908
+ *
909
+ * `{source}` is a placeholder the consumer fills with its own namespace.
910
+ */
911
+ const EXTERNAL_IDS_CANDIDATE = {
912
+ pointer: "/externalIds/{source}",
913
+ scope: "vendor",
914
+ };
915
+
916
+ /**
917
+ * Walk V3 for every array of objects, pairing it with the candidate keys that
918
+ * identify its items, in precedence order. Derived from the schema, so it
919
+ * cannot drift.
920
+ */
921
+ const buildArrayKeys = () => {
922
+ const keyed = [];
923
+ const unkeyed = [];
924
+ const seen = new Set();
925
+
926
+ const walk = (node, pointer, depth) => {
927
+ if (!node || typeof node !== "object" || depth > 16) return;
928
+ if (seen.has(pointer)) return;
929
+ seen.add(pointer);
930
+
931
+ if (node.patternProperties) {
932
+ // Already addressed by key in V3 — offers, enquiries, externalIds.
933
+ for (const child of Object.values(node.patternProperties)) {
934
+ walk(child, `${pointer}/{key}`, depth + 1);
935
+ }
936
+ return;
937
+ }
938
+
939
+ if (node.type === "array" || node.items) {
940
+ const items = node.items || {};
941
+ const itemProps = mergedProperties(items);
942
+ const names = Object.keys(itemProps);
943
+ if (names.length > 0) {
944
+ const candidates = [];
945
+ const canonical = ARRAY_KEY_FIELDS.find((f) => names.includes(f));
946
+ if (canonical) {
947
+ candidates.push({ pointer: `/${canonical}`, scope: "canonical" });
948
+ }
949
+ if (names.includes("externalIds")) {
950
+ candidates.push(EXTERNAL_IDS_CANDIDATE);
951
+ }
952
+ if (candidates.length) keyed.push({ pointer, keys: candidates });
953
+ else unkeyed.push(pointer);
954
+ }
955
+ walk(items, `${pointer}/{index}`, depth + 1);
956
+ return;
957
+ }
958
+
959
+ for (const [name, child] of Object.entries(mergedProperties(node))) {
960
+ walk(child, `${pointer}/${name}`, depth + 1);
961
+ }
962
+ };
963
+
964
+ walk(combined, "", 0);
965
+ return { keyed, unkeyed };
966
+ };
967
+
968
+ const buildRules = () => [
969
+ // --- Title: merged from two V3 arrays -----------------------------------
970
+ {
971
+ id: "title.titlesToBeSold",
972
+ v3Pointer: "/propertyPack/titlesToBeSold/{index}",
973
+ entity: "Title",
974
+ entityPointer: "",
975
+ keys: { mode: "include", values: titlesKeys },
976
+ instance: {
977
+ cardinality: "many",
978
+ orderedBy: { entity: "Transaction", pointer: "/titlesToBeSold" },
979
+ correlateBy: "titleNumber",
980
+ primarySource: true,
981
+ },
982
+ notes:
983
+ "Primary source: a Title carrying only shared keys recomposes to this array.",
984
+ },
985
+ {
986
+ id: "title.ownershipsToBeTransferred",
987
+ v3Pointer: "/propertyPack/ownership/ownershipsToBeTransferred/{index}",
988
+ entity: "Title",
989
+ entityPointer: "",
990
+ keys: { mode: "include", values: ownershipItemKeys },
991
+ instance: {
992
+ cardinality: "many",
993
+ orderedBy: { entity: "Transaction", pointer: "/titlesToBeSold" },
994
+ correlateBy: "titleNumber",
995
+ primarySource: false,
996
+ },
997
+ notes:
998
+ "Indices of the two source arrays are NOT assumed to correspond; they are correlated by titleNumber value.",
999
+ },
1000
+
1001
+ // --- Transaction: residual ownership + legal owners ----------------------
1002
+ {
1003
+ id: "transaction.saleContext.legalOwners",
1004
+ v3Pointer: "/propertyPack/legalOwners",
1005
+ entity: "Transaction",
1006
+ entityPointer: "/saleContext/legalOwners",
1007
+ instance: { cardinality: "single" },
1008
+ },
1009
+ {
1010
+ id: "transaction.saleContext",
1011
+ v3Pointer: "/propertyPack/ownership",
1012
+ entity: "Transaction",
1013
+ entityPointer: "/saleContext",
1014
+ keys: { mode: "exclude", values: ["ownershipsToBeTransferred"] },
1015
+ instance: { cardinality: "single" },
1016
+ },
1017
+
1018
+ // --- Property: everything else in propertyPack ---------------------------
1019
+ {
1020
+ id: "property",
1021
+ v3Pointer: "/propertyPack",
1022
+ entity: "Property",
1023
+ entityPointer: "",
1024
+ keys: { mode: "exclude", values: PROPERTY_EXCLUDE },
1025
+ instance: { cardinality: "single" },
1026
+ },
1027
+
1028
+ // --- Participants: split person facts from relationship facts ------------
1029
+ {
1030
+ id: "transaction.participants",
1031
+ v3Pointer: "/participants/{index}",
1032
+ entity: "Transaction",
1033
+ entityPointer: "/participants/{index}",
1034
+ keys: { mode: "include", values: PARTICIPANT_ROSTER_FIELDS },
1035
+ instance: {
1036
+ cardinality: "many",
1037
+ orderedBy: { entity: "Transaction", pointer: "/participants" },
1038
+ },
1039
+ notes:
1040
+ "The roster only: transaction-scoped identity and firm. The entity reference itself is at /participants/{index}/participant.",
1041
+ },
1042
+
1043
+ // --- Participant relationship fields: each lives on its credential --------
1044
+ // These rules are data-dependent: which credential holds a participant's
1045
+ // role depends on the relationships that exist for them. resolveV3Pointer
1046
+ // returns the candidates; pass the decomposed entities to narrow it.
1047
+ {
1048
+ id: "credential.sellerCapacity",
1049
+ v3Pointer: "/participants/{index}",
1050
+ entity: "SellerCapacity",
1051
+ entityPointer: "",
1052
+ keys: { mode: "include", values: ["sellersCapacity", "dateBecameOwnerOrAuthority"] },
1053
+ instance: { cardinality: "many", matchOn: "/seller" },
1054
+ impliesRole: "Seller",
1055
+ },
1056
+ {
1057
+ id: "credential.offer",
1058
+ v3Pointer: "/participants/{index}",
1059
+ entity: "Offer",
1060
+ entityPointer: "",
1061
+ keys: { mode: "include", values: ["offerId"] },
1062
+ instance: { cardinality: "many", matchOn: "/buyer" },
1063
+ impliesRole: "Buyer",
1064
+ },
1065
+ {
1066
+ id: "credential.gift",
1067
+ v3Pointer: "/participants/{index}",
1068
+ entity: "Gift",
1069
+ entityPointer: "",
1070
+ keys: { mode: "include", values: ["giftDetails", "offerId"] },
1071
+ instance: { cardinality: "many", matchOn: "/donor" },
1072
+ impliesRole: "Gift Donor",
1073
+ },
1074
+ {
1075
+ id: "credential.representation",
1076
+ v3Pointer: "/participants/{index}",
1077
+ entity: "Representation",
1078
+ entityPointer: "",
1079
+ keys: { mode: "include", values: ["role", "actingFor"] },
1080
+ fieldMap: { role: "/role", actingFor: "/representedParty" },
1081
+ instance: { cardinality: "many", matchOn: "/representative" },
1082
+ notes:
1083
+ "actingFor becomes one Representation per represented party; its role is the credential's own discriminator.",
1084
+ },
1085
+ {
1086
+ id: "credential.transactionRole",
1087
+ v3Pointer: "/participants/{index}",
1088
+ entity: "TransactionRole",
1089
+ entityPointer: "",
1090
+ keys: { mode: "include", values: ["role"] },
1091
+ instance: { cardinality: "many", matchOn: "/participant" },
1092
+ notes:
1093
+ "Carries role for the roles no specific credential covers, and for a party whose specific relationship is not yet established.",
1094
+ },
1095
+ {
1096
+ id: "person",
1097
+ v3Pointer: "/participants/{index}",
1098
+ entity: "Person",
1099
+ entityPointer: "",
1100
+ keys: { mode: "include", values: personKeys },
1101
+ instance: {
1102
+ cardinality: "many",
1103
+ orderedBy: {
1104
+ entity: "Transaction",
1105
+ pointer: "/participants",
1106
+ idField: "participant",
1107
+ },
1108
+ },
1109
+ },
1110
+
1111
+ // --- Transaction: all remaining top-level V3 properties ------------------
1112
+ {
1113
+ id: "transaction",
1114
+ v3Pointer: "",
1115
+ entity: "Transaction",
1116
+ entityPointer: "",
1117
+ keys: { mode: "exclude", values: [...TRANSACTION_EXCLUDE, "$schema"] },
1118
+ instance: { cardinality: "single" },
1119
+ },
1120
+ ];
1121
+
1122
+ /**
1123
+ * Generation-time drift guard. Every V3 key that can hold data must be claimed
1124
+ * by at least one rule, and every key a rule claims must exist in the target
1125
+ * entity schema. A new V3 field with no home fails generation rather than
1126
+ * silently disappearing from the round trip.
1127
+ */
1128
+ const verifyCoverage = (rules) => {
1129
+ const errors = [];
1130
+
1131
+ const claimedAt = (pointer) =>
1132
+ rules.filter((r) => r.v3Pointer === pointer);
1133
+
1134
+ const claims = (rulesAtPointer, key) =>
1135
+ rulesAtPointer.some(({ keys }) => {
1136
+ if (!keys) return true;
1137
+ return keys.mode === "include"
1138
+ ? keys.values.includes(key)
1139
+ : !keys.values.includes(key);
1140
+ });
1141
+
1142
+ // A key is covered either by a rule at this node that admits it, or by a
1143
+ // more specific rule rooted at the key itself (e.g. /propertyPack/ownership
1144
+ // is covered by the rules that split it, not by the propertyPack rule).
1145
+ const checkNode = (pointer, sourceKeys, label) => {
1146
+ const rulesHere = claimedAt(pointer);
1147
+ for (const key of sourceKeys) {
1148
+ const childPrefix = `${pointer}/${key}`;
1149
+ const coveredByChild = rules.some(
1150
+ (r) => r.v3Pointer === childPrefix || r.v3Pointer.startsWith(`${childPrefix}/`)
1151
+ );
1152
+ if (!coveredByChild && !claims(rulesHere, key)) {
1153
+ errors.push(`${label}: V3 key "${key}" is not claimed by any mapping rule`);
1154
+ }
1155
+ }
1156
+ };
1157
+
1158
+ checkNode("", Object.keys(combined.properties).filter((k) => k !== "$schema"), "top level");
1159
+ checkNode("/propertyPack", Object.keys(pp), "propertyPack");
1160
+ checkNode("/participants/{index}", [...Object.keys(participantItems.properties), ...participantBranchKeys], "participants item");
1161
+ checkNode("/propertyPack/titlesToBeSold/{index}", titlesKeys, "titlesToBeSold item");
1162
+ checkNode("/propertyPack/ownership/ownershipsToBeTransferred/{index}", ownershipItemKeys, "ownershipsToBeTransferred item");
1163
+ checkNode("/propertyPack/ownership", Object.keys(pp.ownership.properties), "ownership");
1164
+
1165
+ // Every included key must actually be present in the target entity schema.
1166
+ const resolve = (schema, pointer) =>
1167
+ pointer
1168
+ .split("/")
1169
+ .filter(Boolean)
1170
+ .reduce(
1171
+ (node, seg) =>
1172
+ seg === "{index}" ? node?.items : node?.properties?.[seg],
1173
+ schema
1174
+ );
1175
+
1176
+ for (const rule of rules) {
1177
+ if (rule.keys?.mode !== "include") continue;
1178
+ const target = resolve(schemas[rule.entity], rule.entityPointer);
1179
+ for (const key of rule.keys.values) {
1180
+ // A rule may map a V3 key to a differently named target field, e.g.
1181
+ // actingFor becomes Representation.representedParty.
1182
+ const mapped = rule.fieldMap?.[key];
1183
+ const field = mapped ? mapped.slice(1) : key;
1184
+ if (!target?.properties?.[field]) {
1185
+ errors.push(
1186
+ `rule "${rule.id}": ${key}${mapped ? ` (as ${mapped})` : ""} is missing from ${rule.entity}${rule.entityPointer || " (root)"}`
1187
+ );
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ if (errors.length) {
1193
+ console.error("\n✗ V4 mapping coverage check failed:\n");
1194
+ errors.forEach((e) => console.error(` - ${e}`));
1195
+ process.exit(1);
1196
+ }
1197
+ };
1198
+
1199
+ const arrayKeys = buildArrayKeys();
1200
+ const rules = buildRules();
1201
+ verifyCoverage(rules);
1202
+
1203
+ const mapping = {
1204
+ mappingVersion: 1,
1205
+ $id: "https://trust.propdata.org.uk/schemas/v4/mapping.json",
1206
+ description:
1207
+ "Generated mapping between PDTF V3 JSON Pointers and V4 entity documents. Do not edit by hand — regenerate with `npm run generate:v4`.",
1208
+ generator: "src/utils/generateV4Schemas.js",
1209
+ source: SOURCE,
1210
+ entities: Object.fromEntries(
1211
+ Object.entries(schemas).map(([name, schema]) => [
1212
+ name,
1213
+ {
1214
+ $id: schema.$id,
1215
+ kind: DERIVED_ENTITIES.includes(name)
1216
+ ? "derived"
1217
+ : CREDENTIAL_ENTITIES.includes(name)
1218
+ ? "credential"
1219
+ : "standalone",
1220
+ idScheme: name === "Property" || name === "Title" ? "urn" : "did",
1221
+ },
1222
+ ])
1223
+ ),
1224
+ roundTrip: {
1225
+ entities: [...DERIVED_ENTITIES, ...CREDENTIAL_ENTITIES],
1226
+ note:
1227
+ "All of these are required to reconstruct a V3 instance. The credentials are not redundant copies: role and every relationship live only on them, so that revoking a credential removes what it asserts rather than leaving a second copy on the transaction. Organisation is the one entity outside the round trip.",
1228
+ roleImpliedByCredential: ROLE_IMPLIED_BY_CREDENTIAL,
1229
+ strippedKeySuffixes: ["Ref", "Required"],
1230
+ knownExceptions: [
1231
+ "Overlay metadata keys (*Ref, *Required) are schema-level only and are stripped from V4; they never appear in instance data.",
1232
+ "A Title carrying only keys shared by both source arrays recomposes into propertyPack.titlesToBeSold only (primarySource), not into ownershipsToBeTransferred.",
1233
+ "propertyPack.ownership.ownershipsToBeTransferred is canonicalised on recomposition: entries are emitted in propertyPack.titlesToBeSold order rather than their original order. The array is correlated by titleNumber and its order carries no meaning in V3, so this is a deliberate normalisation — it removes the index-divergence class of bug rather than reproducing it. Entry CONTENT is preserved exactly.",
1234
+ "An explicitly empty container recomposes as absent: propertyPack {}, propertyPack.ownership {} and propertyPack.titlesToBeSold [] are all valid V3 but carry no data, and the entity model has no place to record 'present but empty'. (participants [] is invalid V3 — minItems 1 — so it does not arise.)",
1235
+ "recompose always emits $schema, set to source.v3SchemaId. An instance that omitted it gains one; this is a normalisation, since a V3 instance is expected to declare its schema.",
1236
+ "The round trip restores deep equality, NOT byte equality: object key order is not preserved, because keys are regrouped by entity and reassembled. JSON attaches no meaning to key order, but do not compare a recomposed instance to the original by hashing or string equality — compare structurally, or canonicalise first (e.g. JCS) if you need a stable digest.",
1237
+ ],
1238
+ },
1239
+ arrayKeys: arrayKeys.keyed,
1240
+ arrayKeyScopes: {
1241
+ canonical:
1242
+ "Lives in the shared payload, so any party holding the transaction can resolve it. This is what claims and credentials should address.",
1243
+ vendor:
1244
+ "A per-source back-reference under externalIds. Resolvable only by a party that knows that source's namespace, and absent entirely on items that source did not write. Use as a fallback, never as the identifier in a credential presented to a third party.",
1245
+ },
1246
+ unkeyedArrays: {
1247
+ description:
1248
+ "Arrays of objects with no candidate key at all — neither a canonical identifier nor externalIds — so their items can only be addressed by position. Listed so the gap is visible rather than silent. Most are single-source provider payloads replaced wholesale, where position is stable in practice.",
1249
+ pointers: arrayKeys.unkeyed,
1250
+ },
1251
+ projections: {
1252
+ Representation: {
1253
+ description:
1254
+ "A professional representation relationship. Emitted once per (representative, represented party) pair, so a conveyancer instructed jointly by two sellers yields two, and sellers who instruct separate conveyancers yield one each.",
1255
+ source: { entity: "Transaction", pointer: "/participants/{index}" },
1256
+ emitWhen: "/actingFor is present and non-empty",
1257
+ fanOut: "/actingFor",
1258
+ resolveBy: "participantId",
1259
+ fields: {
1260
+ representative: "/participants/{index}/participant",
1261
+ representedParty:
1262
+ "/participants/{index}/actingFor/{n} resolved against /participants/*/participantId",
1263
+ role: "/participants/{index}/role",
1264
+ organisation: "/participants/{index}/organisation",
1265
+ organisationReference: "/participants/{index}/organisationReference",
1266
+ transaction: "/id",
1267
+ },
1268
+ },
1269
+ SellerCapacity: {
1270
+ description:
1271
+ "The capacity in which a seller sells — the relationship that embodies the Seller role. Emitted once per participant whose role is Seller, whether or not a capacity has been declared yet.",
1272
+ source: { entity: "Transaction", pointer: "/participants/{index}" },
1273
+ emitWhen: "/role is \"Seller\", or /sellersCapacity is present",
1274
+ fields: {
1275
+ seller: "/participants/{index}/participant",
1276
+ sellersCapacity: "/participants/{index}/sellersCapacity",
1277
+ dateBecameOwnerOrAuthority:
1278
+ "/participants/{index}/dateBecameOwnerOrAuthority",
1279
+ transaction: "/id",
1280
+ },
1281
+ notes:
1282
+ "V3 does not tie a seller's capacity to a particular title, so the optional `title` reference is left unset. A transaction selling more than one title therefore yields a capacity scoped to the transaction, not to a title.",
1283
+ },
1284
+ TransactionRole: {
1285
+ description:
1286
+ "A party's role where no more specific relationship credential applies — Lender, Landlord, Tenant, Gift Donor, Platform Support — or where the specific relationship is not yet established. Emitted so that every participant carrying a role has exactly one credential embodying it.",
1287
+ source: { entity: "Transaction", pointer: "/participants/{index}" },
1288
+ emitWhen:
1289
+ "/role is present and no other credential names this participant as its role-bearer",
1290
+ fields: {
1291
+ participant: "/participants/{index}/participant",
1292
+ role: "/participants/{index}/role",
1293
+ organisation: "/participants/{index}/organisation",
1294
+ organisationReference: "/participants/{index}/organisationReference",
1295
+ transaction: "/id",
1296
+ },
1297
+ },
1298
+ Offer: {
1299
+ description:
1300
+ "An offer made by a prospective buyer — the relationship that embodies the Buyer role. Emitted once per participant carrying an offerId, merged with that offer's data from Transaction.offers.",
1301
+ source: { entity: "Transaction", pointer: "/participants/{index}" },
1302
+ emitWhen: "/offerId is present",
1303
+ fields: {
1304
+ offerId: "/participants/{index}/offerId",
1305
+ buyer: "/participants/{index}/participant",
1306
+ "…": "the remaining fields from /offers/{offerId}",
1307
+ transaction: "/id",
1308
+ },
1309
+ notes:
1310
+ "An offer in Transaction.offers that no participant references stays transaction data and yields no credential, since Offer requires a buyer.",
1311
+ },
1312
+ },
1313
+ collisions: titleCollisionKeys.map((key) => ({
1314
+ key,
1315
+ entity: "Title",
1316
+ sources: [
1317
+ `/propertyPack/titlesToBeSold/{index}/${key}`,
1318
+ `/propertyPack/ownership/ownershipsToBeTransferred/{index}/${key}`,
1319
+ ],
1320
+ rule: "same-fact",
1321
+ resolution:
1322
+ "Identical fact in both sources and the key that correlates them. Decomposition takes either; recomposition writes the value back to every source array it emits an entry for.",
1323
+ })),
1324
+ rules,
1325
+ };
1326
+
1327
+ for (const [name, schema] of Object.entries(schemas)) {
1328
+ fs.writeFileSync(
1329
+ path.join(V4_DIR, `${name}.json`),
1330
+ JSON.stringify(schema, null, 2) + "\n"
1331
+ );
1332
+ console.log(`✓ ${name}.json`);
1333
+ }
1334
+
1335
+ fs.writeFileSync(
1336
+ path.join(V4_DIR, "mapping.json"),
1337
+ JSON.stringify(mapping, null, 2) + "\n"
1338
+ );
1339
+ console.log("✓ mapping.json");
1340
+
1341
+ console.log(`\nAll V4 schemas written to ${V4_DIR}`);