@pdtf/schemas 3.6.0-dev.3 → 3.6.0-dev.32

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