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

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 (192) hide show
  1. package/docs/ta6-certificate-permit-contract.md +51 -0
  2. package/docs/v4-mapping-and-recomposition.md +419 -0
  3. package/index.js +210 -0
  4. package/package.json +10 -1
  5. package/src/schemas/v3/combined.json +2261 -657
  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 +68 -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/Representation.json +67 -0
  46. package/src/schemas/v4/SellerCapacity.json +193 -0
  47. package/src/schemas/v4/Title.json +14660 -0
  48. package/src/schemas/v4/Transaction.json +1612 -0
  49. package/src/schemas/v4/TransactionRole.json +59 -0
  50. package/src/schemas/v4/mapping.json +1161 -0
  51. package/src/utils/checkTa6OmissionCompatibility.js +146 -0
  52. package/src/utils/extractExtensionOverlays.js +11 -2
  53. package/src/utils/extractOverlay.js +21 -0
  54. package/src/utils/generateV4Schemas.js +1358 -0
  55. package/src/utils/syncTa6ed6v2Markers.js +92 -0
  56. package/src/utils/v4.js +838 -0
  57. package/CLAUDE.md +0 -101
  58. package/pensive-tesla-d0d355/CLAUDE.md +0 -101
  59. package/pensive-tesla-d0d355/LICENSE +0 -21
  60. package/pensive-tesla-d0d355/README.md +0 -435
  61. package/pensive-tesla-d0d355/docs/branch-release-workflow.md +0 -138
  62. package/pensive-tesla-d0d355/docs/sef25-extensions-ui-spec.md +0 -355
  63. package/pensive-tesla-d0d355/index.js +0 -654
  64. package/pensive-tesla-d0d355/package-lock.json +0 -7297
  65. package/pensive-tesla-d0d355/package.json +0 -44
  66. package/pensive-tesla-d0d355/src/examples/v1/exampleAddParticipantVouch.json +0 -32
  67. package/pensive-tesla-d0d355/src/examples/v1/exampleDocumentedVouch.json +0 -40
  68. package/pensive-tesla-d0d355/src/examples/v1/exampleElectronicRecord.json +0 -30
  69. package/pensive-tesla-d0d355/src/examples/v1/exampleTransaction.json +0 -878
  70. package/pensive-tesla-d0d355/src/examples/v1/exampleVouch.json +0 -25
  71. package/pensive-tesla-d0d355/src/examples/v2/exampleTransaction.json +0 -901
  72. package/pensive-tesla-d0d355/src/examples/v3/exampleAddParticipantVouch.json +0 -38
  73. package/pensive-tesla-d0d355/src/examples/v3/exampleCustomOverlay.json +0 -55
  74. package/pensive-tesla-d0d355/src/examples/v3/exampleCustomOverlay2.json +0 -93
  75. package/pensive-tesla-d0d355/src/examples/v3/exampleDocumentedVouch.json +0 -47
  76. package/pensive-tesla-d0d355/src/examples/v3/exampleElectronicRecord.json +0 -36
  77. package/pensive-tesla-d0d355/src/examples/v3/exampleTransaction.json +0 -1038
  78. package/pensive-tesla-d0d355/src/examples/v3/exampleVouch.json +0 -32
  79. package/pensive-tesla-d0d355/src/mappings/SendPropertyDetailsRequestSchema.json +0 -1588
  80. package/pensive-tesla-d0d355/src/mappings/adf.js +0 -198
  81. package/pensive-tesla-d0d355/src/schemas/v1/GeoJSON.json +0 -1279
  82. package/pensive-tesla-d0d355/src/schemas/v1/baspiNotes.html +0 -102
  83. package/pensive-tesla-d0d355/src/schemas/v1/energy-performance-certificate.json +0 -124
  84. package/pensive-tesla-d0d355/src/schemas/v1/freehold-information.json +0 -2051
  85. package/pensive-tesla-d0d355/src/schemas/v1/leasehold-information.json +0 -2064
  86. package/pensive-tesla-d0d355/src/schemas/v1/legal-information.json +0 -1045
  87. package/pensive-tesla-d0d355/src/schemas/v1/material-facts.json +0 -2109
  88. package/pensive-tesla-d0d355/src/schemas/v1/pdtf-transaction-search-results.json +0 -40
  89. package/pensive-tesla-d0d355/src/schemas/v1/pdtf-transaction.json +0 -191
  90. package/pensive-tesla-d0d355/src/schemas/v1/searches/drainage-and-water.json +0 -261
  91. package/pensive-tesla-d0d355/src/schemas/v1/searches/local-land-charges.json +0 -69
  92. package/pensive-tesla-d0d355/src/schemas/v1/searches/local-searches-required.json +0 -857
  93. package/pensive-tesla-d0d355/src/schemas/v1/searches.json +0 -237
  94. package/pensive-tesla-d0d355/src/schemas/v1/title-deed.json +0 -841
  95. package/pensive-tesla-d0d355/src/schemas/v2/baspiNotes.html +0 -102
  96. package/pensive-tesla-d0d355/src/schemas/v2/combined.json +0 -37731
  97. package/pensive-tesla-d0d355/src/schemas/v2/overlays/baspi.json +0 -8132
  98. package/pensive-tesla-d0d355/src/schemas/v2/overlays/con29DW.json +0 -705
  99. package/pensive-tesla-d0d355/src/schemas/v2/overlays/con29R.json +0 -2956
  100. package/pensive-tesla-d0d355/src/schemas/v2/overlays/fme1.json +0 -1259
  101. package/pensive-tesla-d0d355/src/schemas/v2/overlays/llc1.json +0 -25
  102. package/pensive-tesla-d0d355/src/schemas/v2/overlays/lpe1.json +0 -2171
  103. package/pensive-tesla-d0d355/src/schemas/v2/overlays/nts.json +0 -326
  104. package/pensive-tesla-d0d355/src/schemas/v2/overlays/oc1.json +0 -2799
  105. package/pensive-tesla-d0d355/src/schemas/v2/overlays/piq.json +0 -3419
  106. package/pensive-tesla-d0d355/src/schemas/v2/overlays/rds.json +0 -3736
  107. package/pensive-tesla-d0d355/src/schemas/v2/overlays/ta10.json +0 -5306
  108. package/pensive-tesla-d0d355/src/schemas/v2/overlays/ta6.json +0 -4533
  109. package/pensive-tesla-d0d355/src/schemas/v2/overlays/ta7.json +0 -1045
  110. package/pensive-tesla-d0d355/src/schemas/v2/pdtf-transaction.json +0 -34351
  111. package/pensive-tesla-d0d355/src/schemas/v2/skeleton.json +0 -3973
  112. package/pensive-tesla-d0d355/src/schemas/v3/combined.json +0 -48469
  113. package/pensive-tesla-d0d355/src/schemas/v3/compactSkeleton.txt +0 -4519
  114. package/pensive-tesla-d0d355/src/schemas/v3/overlays/README.md +0 -299
  115. package/pensive-tesla-d0d355/src/schemas/v3/overlays/baspi4.json +0 -8025
  116. package/pensive-tesla-d0d355/src/schemas/v3/overlays/baspi5.json +0 -8175
  117. package/pensive-tesla-d0d355/src/schemas/v3/overlays/con29DW.json +0 -716
  118. package/pensive-tesla-d0d355/src/schemas/v3/overlays/con29R.json +0 -2981
  119. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ac.json +0 -333
  120. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/as.json +0 -74
  121. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/dk.json +0 -32
  122. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/dr.json +0 -73
  123. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/er.json +0 -85
  124. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/fd.json +0 -75
  125. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/hi.json +0 -51
  126. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/hs.json +0 -73
  127. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ic.json +0 -59
  128. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/jk.json +0 -96
  129. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/la.json +0 -85
  130. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/lc.json +0 -75
  131. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ma.json +0 -90
  132. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/mc.json +0 -38
  133. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/mi.json +0 -69
  134. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/nd.json +0 -43
  135. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/oa.json +0 -24
  136. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/oc.json +0 -57
  137. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/pc.json +0 -37
  138. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ph.json +0 -194
  139. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/rw.json +0 -68
  140. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sb.json +0 -73
  141. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sc.json +0 -129
  142. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sd.json +0 -63
  143. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sf.json +0 -74
  144. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sl.json +0 -68
  145. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ta.json +0 -252
  146. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/tf.json +0 -103
  147. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/tr.json +0 -73
  148. package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/wg.json +0 -350
  149. package/pensive-tesla-d0d355/src/schemas/v3/overlays/fme1.json +0 -1266
  150. package/pensive-tesla-d0d355/src/schemas/v3/overlays/llc1.json +0 -25
  151. package/pensive-tesla-d0d355/src/schemas/v3/overlays/lpe1.json +0 -2199
  152. package/pensive-tesla-d0d355/src/schemas/v3/overlays/nts.json +0 -2157
  153. package/pensive-tesla-d0d355/src/schemas/v3/overlays/nts2.json +0 -2742
  154. package/pensive-tesla-d0d355/src/schemas/v3/overlays/ntsl.json +0 -1813
  155. package/pensive-tesla-d0d355/src/schemas/v3/overlays/ntsl2.json +0 -2217
  156. package/pensive-tesla-d0d355/src/schemas/v3/overlays/oc1.json +0 -2817
  157. package/pensive-tesla-d0d355/src/schemas/v3/overlays/piq.json +0 -3322
  158. package/pensive-tesla-d0d355/src/schemas/v3/overlays/rds.json +0 -3665
  159. package/pensive-tesla-d0d355/src/schemas/v3/overlays/sef25.json +0 -1639
  160. package/pensive-tesla-d0d355/src/schemas/v3/overlays/sr24.json +0 -38
  161. package/pensive-tesla-d0d355/src/schemas/v3/overlays/ta10.json +0 -4654
  162. package/pensive-tesla-d0d355/src/schemas/v3/overlays/ta6ed6.json +0 -6534
  163. package/pensive-tesla-d0d355/src/schemas/v3/overlays/ta7.json +0 -1537
  164. package/pensive-tesla-d0d355/src/schemas/v3/overlays/ta7ed5.json +0 -1551
  165. package/pensive-tesla-d0d355/src/schemas/v3/pdtf-transaction.json +0 -41573
  166. package/pensive-tesla-d0d355/src/schemas/v3/skeleton.json +0 -5767
  167. package/pensive-tesla-d0d355/src/schemas/verifiedClaims/README.md +0 -21
  168. package/pensive-tesla-d0d355/src/schemas/verifiedClaims/pdtf-verified-claims.json +0 -447
  169. package/pensive-tesla-d0d355/src/utils/compactSkeleton.js +0 -84
  170. package/pensive-tesla-d0d355/src/utils/countTokens.js +0 -67
  171. package/pensive-tesla-d0d355/src/utils/extractExtensionOverlays.js +0 -624
  172. package/pensive-tesla-d0d355/src/utils/extractOverlay.js +0 -381
  173. package/pensive-tesla-d0d355/src/utils/extractProperties.js +0 -44
  174. package/pensive-tesla-d0d355/src/utils/listPathTitles.js +0 -36
  175. package/pensive-tesla-d0d355/src/utils/minimalSkeleton.js +0 -85
  176. package/pensive-tesla-d0d355/src/utils/pathList.csv +0 -903
  177. package/pensive-tesla-d0d355/src/utils/pathSkeleton.js +0 -181
  178. package/src/examples/v1/exampleAddParticipantVouch.json +0 -32
  179. package/src/examples/v1/exampleDocumentedVouch.json +0 -40
  180. package/src/examples/v1/exampleElectronicRecord.json +0 -30
  181. package/src/examples/v1/exampleTransaction.json +0 -878
  182. package/src/examples/v1/exampleVouch.json +0 -25
  183. package/src/examples/v2/exampleTransaction.json +0 -901
  184. package/src/examples/v3/exampleAddParticipantVouch.json +0 -38
  185. package/src/examples/v3/exampleCustomOverlay.json +0 -55
  186. package/src/examples/v3/exampleCustomOverlay2.json +0 -93
  187. package/src/examples/v3/exampleDocumentedVouch.json +0 -47
  188. package/src/examples/v3/exampleElectronicRecord.json +0 -36
  189. package/src/examples/v3/exampleTransaction.json +0 -1038
  190. package/src/examples/v3/exampleVouch.json +0 -32
  191. package/src/mappings/SendPropertyDetailsRequestSchema.json +0 -1588
  192. package/src/mappings/adf.js +0 -198
@@ -0,0 +1,51 @@
1
+ # TA6 certificate and permit document contract
2
+
3
+ Candidate: `@pdtf/schemas@3.6.0-dev.30`, targeting `dev`. Published baseline checked on 27 October 2026: `3.6.0-dev.29`. Publication requires release approval; this document does not assert publication.
4
+
5
+ ## Paths and refs
6
+
7
+ Both `ta6ed6` and `ta6ed6v2` use the same stored paths and values.
8
+
9
+ | Path | Values | TA6 ref in both overlays |
10
+ | --- | --- | --- |
11
+ | `/propertyPack/electricalWorks/electricalWorkSince2005/suppliedCertificate/yesNo` | `Yes`, `No` | `11.2.1` |
12
+ | `/propertyPack/electricalWorks/electricalWorkSince2005/suppliedCertificate/attachments` | `Attached`, `To follow` | `11.2.2` (formerly `11.2`) |
13
+ | `/propertyPack/waterAndDrainage/drainage/mainsFoulDrainage/offMainsDrainageSystem/permitDocuments` | `Attached`, `To follow`, `Not applicable` | `11.7j` (unchanged) |
14
+
15
+ The certificate object retains ref `11.2` and the existing `certificateType` and `attachments` paths. `certificateType` remains defined in the base and BASPI/TA6 fourth-edition overlays; it is not newly displayed in sixth-edition forms.
16
+
17
+ ## Compatibility and requiredness
18
+
19
+ The new answer is a flat optional string. It has no default, discriminator, `oneOf`, or additional requiredness. The sixth-edition attachment requirement is removed. Explicit No needs neither attachments nor certificate type; Yes supports both attachment choices and can be saved without a status. Attachment-only records remain valid. An empty certificate object remains unanswered, never inferred as No. Existing outer electrical-work conditions and requirements for details and the certificate object are unchanged.
20
+
21
+ Permit documents stay optional in all four applicable branches: Septic tank, Cesspit, Sewerage treatment plant and Other. Absence stays absent.
22
+
23
+ No other v3 overlay or extension overlay artifact changes. The shared base acquires the optional certificate field and wider permit enum; consumers validating these paths with the base therefore see the same additions. BASPI and TA6 fourth-edition certificate requirements are unchanged, including when combined with a sixth-edition overlay. Heating/mains-gas schema is unchanged.
24
+
25
+ Differential verification against the registry tarball uses `getSubschemaValidator` and `getValidator` with complete baseline-valid transaction fixtures for both editions:
26
+
27
+ ```bash
28
+ node src/utils/checkTa6OmissionCompatibility.js /path/to/unpacked/dev.29/package
29
+ ```
30
+
31
+ 352 comparisons: legacy shapes have 64 valid-to-valid, 24 invalid-to-valid, 64 invalid-to-invalid and **zero valid-to-invalid** results. New-answer cases have 32 valid-to-valid, 16 invalid-to-valid, 104 invalid-to-invalid and 48 valid-to-invalid results. The 48 tightenings are exclusively `Maybe`, empty string or null already stored at the previously unconstrained new `yesNo` path, combined with a valid legacy attachment status (including both validators and editions). This is not a claim of universal compatibility with arbitrary additional-property data; no production corpus scan was performed.
32
+
33
+ ## Buyer Ready dependency
34
+
35
+ Buyer Ready main `6d2e4cbfe` pins dev.29 in its lockfile. Sellers need a frontend dependency and lockfile bump to the published candidate version, followed by a frontend release, to see these choices. A backend-only bump does not update the seller UI.
36
+
37
+ 22 mounted `FormElement.vue` tests against the candidate package confirm both overlays: the certificate answer and attachment status are optional; attachment choices remain visible for Yes, No and unanswered; no update/default is emitted on mount; the existing outer work-Yes condition is preserved; and Not applicable is available in each drainage branch. The current UI does not hide attachment controls on certificate No. The backend must suppress stale attachment ticks, without deleting seller data. No frontend product code change is needed for basic visibility or requiredness. These are component-level checks, not a deployed browser acceptance claim.
38
+
39
+ ## Backend mapping contract
40
+
41
+ For §11.2, explicit Yes/No takes precedence. No ticks only `11.2 no` and suppresses stale attachment ticks. Yes ticks `11.2 yes` and uses the attachment status. With no answer, Attached/To follow retain the legacy Yes inference. With neither, leave all four boxes blank.
42
+
43
+ For §11.7(j), use the actual `11.7j na` widget. The three statuses are mutually exclusive. Prefer `permitDocuments`; fall back to `plantRegistered/attachments` only when the newer value is absent. Explicit new values override conflicting legacy statuses.
44
+
45
+ The backend MR is separate from the six mapping fixes in MR !4579 (open at initial verification, head `7f650a32f`). It must not merge or deploy without approval. Do not regenerate signed PDFs or modify production claims.
46
+
47
+ ## Release checks
48
+
49
+ Edit only `src/schemas/v3/combined.json`; regenerate with `extract-overlays`, `extract-extension-overlays`, and `generate:v4` after the package version bump. All v4 headers must carry the candidate version and combined-source SHA-256.
50
+
51
+ After approval: merge the schema PR into dev, publish with `npm run publish:dev`, verify the exact npm version and dev dist-tag from the registry, then bump backend/frontend package metadata and lockfiles to that verified version. Recheck concurrent versions before publishing. Never publish this candidate to latest.
@@ -0,0 +1,419 @@
1
+ # V4 mapping and recomposition
2
+
3
+ PDTF 1.x verified claims are JSON Pointers against the V3 transaction schema.
4
+ PDTF 2.0 credentials are per-entity documents. The V4 generator
5
+ (`src/utils/generateV4Schemas.js`) already encodes every rule needed to move
6
+ between the two — but until now it emitted only schemas, so any consumer
7
+ wanting to map a claim onto a credential (or back) had to re-implement those
8
+ rules by hand and then keep them in step.
9
+
10
+ This adds three things, all produced by the same generation run:
11
+
12
+ | Artefact | What it is |
13
+ | --- | --- |
14
+ | `src/schemas/v4/mapping.json` | Generated manifest: V3 pointer → entity + pointer, and the inverse |
15
+ | `src/utils/v4.js` | `decompose` / `recompose` / `resolveV3Pointer`, driven entirely by the manifest |
16
+ | `src/tests/v4/roundTrip.test.js` | V3 → entities → V3 equality, with documented exceptions |
17
+
18
+ Regenerate everything with:
19
+
20
+ ```bash
21
+ npm run generate:v4
22
+ ```
23
+
24
+ The generator remains read-only against V3.
25
+
26
+ ## Using it
27
+
28
+ ```js
29
+ const {
30
+ decomposeToV4,
31
+ recomposeFromV4,
32
+ resolveV3Pointer,
33
+ v4Mapping,
34
+ } = require("@pdtf/schemas");
35
+
36
+ const entities = decomposeToV4(v3Transaction);
37
+ // { Property, Title: [...], Transaction, Person: [...] }
38
+
39
+ const v3Again = recomposeFromV4(entities);
40
+
41
+ // Map a verified-claim pointer onto the credential that now carries it
42
+ resolveV3Pointer("/propertyPack/ownership/ownershipsToBeTransferred/1/ownershipType");
43
+ // { entity: "Title", entityPointer: "/ownershipType", rule: "title.ownershipsToBeTransferred" }
44
+ ```
45
+
46
+ Pass `idFactory` to `decomposeToV4` to mint real URNs/DIDs; the default is
47
+ deterministic and derived from `transactionId`, so repeated runs are stable and
48
+ diffable. A supplied factory may be **partial** — anything it does not implement
49
+ falls back to the default, so adding an entity type here cannot break a caller's
50
+ factory on upgrade.
51
+
52
+ ## The manifest
53
+
54
+ Rules are **prefix rewrites**, not a leaf enumeration. A V3 pointer is resolved
55
+ by longest-prefix match against `rule.v3Pointer` (with `{index}` matching an
56
+ array index), which means pointers far deeper than anything listed — the kind
57
+ claims actually carry — resolve correctly, and the file does not churn every
58
+ time a field is added to V3.
59
+
60
+ `mapping.source` records exactly which V3 input produced the output:
61
+
62
+ ```json
63
+ {
64
+ "packageVersion": "3.6.0-31",
65
+ "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
66
+ "combinedSha256": "…"
67
+ }
68
+ ```
69
+
70
+ The same block is stamped on every entity schema as `x-pdtf-source`, so a
71
+ consumer holding a single schema can still pin.
72
+
73
+ The content hash is deliberately the only identifier. A commit sha would tie
74
+ generated output to git history: it could never be correct in the same commit
75
+ that changes `combined.json`, and it would churn on every rebase or cherry-pick
76
+ without the schema content differing at all. To find the commit for a hash,
77
+ search the history for it.
78
+
79
+ ### It cannot drift
80
+
81
+ `verifyCoverage()` runs at generation time and **fails the build** if any V3 key
82
+ is not claimed by a rule, or if a rule claims a key that is absent from its
83
+ target entity schema. Adding a field to V3 that has no home in V4 is a hard
84
+ error rather than a field that silently disappears from the round trip.
85
+
86
+ ## The four ambiguous cases
87
+
88
+ ### 1. `Title` merges two V3 arrays
89
+
90
+ `propertyPack.titlesToBeSold` and
91
+ `propertyPack.ownership.ownershipsToBeTransferred` both contribute to `Title`.
92
+
93
+ - **Key collision:** `titleNumber`, and only `titleNumber`. It is the same fact
94
+ on both sides, and it is declared in `mapping.collisions`.
95
+ - **How the indices correlate: they don't.** The arrays are correlated by
96
+ `titleNumber` value. A V3 instance may order them differently or list a title
97
+ on only one side, and the tests cover both.
98
+ - **Recomposition** splits a `Title` back by the `ownedKeys` each rule declares.
99
+ A title carrying only shared keys goes to `titlesToBeSold` alone
100
+ (`instance.primarySource`).
101
+
102
+ ### 2. `propertyPack.ownership` splits two ways
103
+
104
+ `ownershipsToBeTransferred` → `Title`; everything else →
105
+ `Transaction.saleContext`.
106
+
107
+ `propertyPack.legalOwners` also lands in `saleContext`. It is excluded from
108
+ `Property` and, being an unidentified name list with no link to `participants`,
109
+ cannot be correlated to `Person`/`Organisation` entities. It is a sale-level
110
+ ownership fact of the same family as `numberOfSellers` and
111
+ `isLimitedCompanySale`, so it sits alongside them — which keeps the round trip
112
+ lossless. Previously it was dropped entirely.
113
+
114
+ ### 3. Relationship fields on `participants`
115
+
116
+ `role`, `sellersCapacity`, `organisation`, `organisationReference`,
117
+ `participantId` and `actingFor` describe a party's relationship to *this
118
+ transaction*, not the party, so they stay off `Person`. They are carried on
119
+ `Transaction.participants[]`, each entry pairing a `participant` DID with those
120
+ fields.
121
+
122
+ This means the round trip needs only four entities — `Property`, `Title`,
123
+ `Transaction`, `Person` (`mapping.roundTrip.entities`). `Organisation`,
124
+ `Representation`, `SellerCapacity` and `Offer` are credential-facing
125
+ projections of data those four already carry; they are not required to
126
+ reconstruct V3.
127
+
128
+ ### 4. Array order
129
+
130
+ V3 arrays become id-keyed entities, so order has to live somewhere. It lives on
131
+ `Transaction` — `titlesToBeSold` and `participants` are ordered reference lists,
132
+ and they are authoritative for recomposition. No index or provenance field is
133
+ added to the entity documents themselves, so a credential never carries a
134
+ position that could diverge from another producer's.
135
+
136
+ ## Documented round-trip exceptions
137
+
138
+ `mapping.roundTrip.knownExceptions` carries these at runtime:
139
+
140
+ 1. **Overlay metadata** (`*Ref`, `*Required` keys) is stripped from V4. It is
141
+ schema-level only and never appears in instance data.
142
+ 2. `Transaction.titlesToBeSold` lists the titles V3 listed under
143
+ `propertyPack.titlesToBeSold`. A title known only from
144
+ `ownershipsToBeTransferred` is a `Title` entity but is not listed there, which
145
+ is how recomposition tells the two apart — and why a title whose only listed
146
+ key is `titleNumber` still gets its entry back.
147
+ 3. **`ownershipsToBeTransferred` order is canonicalised** to `titlesToBeSold`
148
+ order. The array is correlated by `titleNumber` and its order carries no
149
+ meaning in V3, so this is a deliberate normalisation that removes the
150
+ index-divergence class of bug rather than reproducing it. Entry *content* is
151
+ preserved exactly.
152
+
153
+ 4. **An explicitly empty container recomposes as absent** — `propertyPack {}`,
154
+ `propertyPack.ownership {}` and `propertyPack.titlesToBeSold []` are all valid
155
+ V3 but carry no data, and the entity model has no place to record "present but
156
+ empty". (`participants []` is invalid V3 — `minItems: 1` — so it does not
157
+ arise.)
158
+ 5. **`recompose` always emits `$schema`**, set to `source.v3SchemaId`. An
159
+ instance that omitted one gains it.
160
+
161
+ Everything else round-trips to deep equality, including the full example
162
+ transaction in `src/examples/v3/exampleTransaction.json`.
163
+
164
+ ## What the round trip is actually tested against
165
+
166
+ Worth being precise about, because "it round-trips" is easy to overclaim:
167
+
168
+ | | |
169
+ | --- | --- |
170
+ | `exampleTransaction.json` | One real instance — roughly **12% of the V3 leaf-path surface** (456 of ~3,300 paths) |
171
+ | Synthetic awkward instance | The structural transforms: two-array merge, index divergence, one-sided titles |
172
+ | `roundTripEdges.test.js` | The six top-level keys the example never touches, plus the boundaries below |
173
+
174
+ The untested majority is `propertyPack` subtree, which passes through wholesale
175
+ under a single prefix rule — low risk, but not zero. The transforms that could
176
+ actually lose data (title merge/split, participant split, ownership split) are
177
+ covered directly.
178
+
179
+ **Refused rather than guessed:** a `titleNumber` repeated within one source
180
+ array throws. It is valid V3 — neither array declares uniqueness — but a title
181
+ number identifies a title, so merging the two entries would silently drop one.
182
+
183
+ **Not yet done:** generating fixtures from the schema to exercise the remaining
184
+ ~88%, and round-tripping real production transactions. Either would turn "no
185
+ known failures" into something stronger.
186
+
187
+ ## Defects this surfaced in the generated schemas
188
+
189
+ Two fields were being lost because the generator read only `node.properties` and
190
+ ignored V3's `discriminator` + `oneOf` branches:
191
+
192
+ - **`Title` was missing every tenure detail** — `leaseholdInformation`,
193
+ `managedFreeholdOrCommonholdInformation`, `estateRentcharges`,
194
+ `wholeFreeholdForSale`, `otherOwnershipDetails` all live on
195
+ `ownershipsToBeTransferred` branches.
196
+ - **`Person`/`SellerCapacity` were missing** `sellersCapacity`,
197
+ `dateBecameOwnerOrAuthority` and `offerId`, which live on `participants`
198
+ branches. `SellerCapacity` was hand-authored with a four-value `capacityType`
199
+ enum against V3's nine-value `capacity`.
200
+
201
+ `mergedProperties()` now folds branch properties in, and `SellerCapacity` is
202
+ derived from the V3 seller branch rather than hand-authored.
203
+
204
+
205
+ ## Representation
206
+
207
+ V3 previously referred to people **by role only** (`originatorRole`,
208
+ `destinationRole` on enquiry messages) and nothing anywhere referenced a
209
+ specific participant. That made a representation relationship underivable — you
210
+ could see that a transaction had a "Seller's Conveyancer", but not which seller
211
+ they acted for — and it made two sellers with separate conveyancers
212
+ unrepresentable, because both participants would carry the same role with
213
+ nothing to tell them apart.
214
+
215
+ Two additive, optional fields close that gap:
216
+
217
+ | Field | Purpose |
218
+ | --- | --- |
219
+ | `participants[].participantId` | Stable identity within the transaction |
220
+ | `participants[].actingFor` | The `participantId`s this participant is instructed by |
221
+
222
+ Both follow the existing key idiom (`^[A-Za-z0-9][A-Za-z0-9._:+-]*$`, the same
223
+ pattern `offers` and `enquiries` keys use). Neither carries a form `*Ref`, so no
224
+ overlay changes, and both are optional, so existing instances stay valid.
225
+
226
+ ### Cardinality
227
+
228
+ One `Representation` per **(representative, represented party)** pair.
229
+
230
+ - A conveyancer instructed jointly by two sellers → `actingFor: ["s1", "s2"]` →
231
+ **two** Representations. Each is a separate statement that can be presented,
232
+ or revoked, on its own.
233
+ - A separated couple instructing their own conveyancers → two representatives,
234
+ each `actingFor` one seller → **one each**. This is the case the old
235
+ role-only model could not express at all.
236
+
237
+ ### A conveyancer is instructed by a person, not by an offer
238
+
239
+ `actingFor` references participants. The retainer is with the client, so a
240
+ buyer's conveyancer acts for the buyer; `participants[].offerId` remains the
241
+ separate link from that buyer to their offer. This also means a **prospective
242
+ buyer**, who has no `offerId` until they make an offer, can still be
243
+ represented — which a buyer-via-offer model could not do.
244
+
245
+ ### The relationship credentials
246
+
247
+ `Representation`, `SellerCapacity`, `Offer`, `Gift` and `TransactionRole` are
248
+ `kind: "credential"`. They are the linking entities that **embody a party's
249
+ role**, and role is stored nowhere else.
250
+
251
+ | Credential | Asserts | Role |
252
+ | --- | --- | --- |
253
+ | `SellerCapacity` | this person sells, in this capacity | **implied**: Seller |
254
+ | `Offer` | this person made this offer | **implied**: Buyer |
255
+ | `Gift` | this person gifts funds towards a purchase | **implied**: Gift Donor |
256
+ | `Representation` | this party is instructed by that party | explicit — which kind |
257
+ | `TransactionRole` | this party takes this role | explicit — which role |
258
+
259
+ For the first three, role is implied 1:1 by the credential's existence: V3 puts
260
+ `sellersCapacity` only on the Seller branch and `giftDetails` only on Gift Donor.
261
+ `offerId` is on the Buyer **and** Gift Donor branches, so a donor's offer link
262
+ lives on `Gift` rather than `Offer`, which keeps `Offer ⇒ Buyer` exact.
263
+
264
+ `Representation` and `TransactionRole` each span several roles, so they carry
265
+ role as their own discriminator. That is not a duplicated participant attribute:
266
+ "what kind of representation is this" is a property of the representation, and
267
+ asserting a role is `TransactionRole`'s entire purpose. Representation's role
268
+ cannot be derived from the edge — acting for a seller tells you the side, not
269
+ whether the party is the conveyancer, the agent or the surveyor.
270
+
271
+ ### Identity: `participants[].did`
272
+
273
+ V3 participants carry a `did` — the party's own identifier, minted when the
274
+ party is created, so in practice always present. V4 uses it verbatim as
275
+ `Person.id`; the `idFactory` mints one only for instances that carry none.
276
+
277
+ That matters because identity must be stable *across* transactions. A person
278
+ selling one property and buying another appears in two transactions, and should
279
+ be the same subject in both — one wallet, accumulating credentials, not one
280
+ identity per participation. A transaction-scoped id (`did:web:…:transactions:
281
+ <txId>:participants:<pid>`) cannot do that; `did:key` minted per person can.
282
+
283
+ `participantId` remains as the transaction-local fallback for implementations
284
+ that do not mint DIDs. `actingFor` names a party by `did` where one exists and
285
+ by `participantId` otherwise.
286
+
287
+ **One party, one entry.** A `did` repeated within a transaction is the same
288
+ party listed twice, which `decompose` refuses: a transaction is a single sale,
289
+ so nobody is both its buyer and its seller. The same person across a sale and an
290
+ onward purchase is two transactions, each with its own credentials, and needs
291
+ nothing special.
292
+
293
+ ### Why role is not on the participant
294
+
295
+ Because a second copy would not be revoked.
296
+
297
+ Firing a conveyancer means revoking their `Representation`. If the transaction
298
+ also recorded `role: "Seller's Conveyancer"` against that participant, the
299
+ revocation would remove the relationship and leave the role assertion standing —
300
+ two sources of truth, disagreeing, with the stale one still readable.
301
+
302
+ So `Transaction.participants[]` is a **roster**, carrying only what remains true
303
+ of a party regardless of any relationship:
304
+
305
+ ```jsonc
306
+ { "participant": "did:pdtf:person-tx-c1",
307
+ "participantId": "c1",
308
+ "organisation": "Suemme and Profitt",
309
+ "organisationReference": "R1" }
310
+ ```
311
+
312
+ `organisation` stays here rather than on the credential for the same
313
+ single-home reason: where someone works does not stop being true when a
314
+ representation ends. A credential names its parties by DID, so resolving the
315
+ representative gives the firm.
316
+
317
+ ### They are part of the round trip
318
+
319
+ Because role and every relationship live only on the credentials, `recompose`
320
+ needs them — they are not redundant copies. `decompose` therefore returns them
321
+ alongside the other entities:
322
+
323
+ ```js
324
+ const { decomposeToV4, recomposeFromV4 } = require("@pdtf/schemas");
325
+
326
+ const entities = decomposeToV4(v3Transaction);
327
+ // { Property, Title, Transaction, Person,
328
+ // Representation, SellerCapacity, Offer, Gift, TransactionRole }
329
+
330
+ recomposeFromV4(entities); // === v3Transaction
331
+ ```
332
+
333
+ Dropping a credential and recomposing gives back a V3 instance in which that
334
+ party has no role and no relationship — which is exactly what revocation should
335
+ mean, and is asserted by a test.
336
+
337
+ Every participant carrying a role receives exactly one role-bearing credential:
338
+ the specific one where the relationship exists, `TransactionRole` otherwise. A
339
+ participant with no role receives none.
340
+
341
+ `SellerCapacity` is scoped to the transaction rather than to a title, since the
342
+ transaction already carries the titles to be sold. It is emitted for **every**
343
+ seller, whether or not a capacity has been declared, so the credential embodying
344
+ the Seller role is never missing while a form is still being filled in.
345
+
346
+ ### If you want an identifier everyone can mint
347
+
348
+ Three patterns, of which V3 already uses two:
349
+
350
+ - **First-writer-mints** — whoever adds the item sets the canonical id, and
351
+ everyone else adopts it because it is in the shared payload. What the seven new
352
+ identifiers assume. A source can simply use its own id as the value, promoting
353
+ it out of its private namespace. Its failure mode is duplication, not ambiguity:
354
+ two parties adding the same document independently mint two ids.
355
+ - **Authority-issued** — `uprn`, `titleNumber`, `lmkKey` and `hmlrReference`
356
+ already work this way. It needs a registry and one authoritative issuer, which
357
+ exists for properties, titles and EPCs but not for documents. Transactions are
358
+ identified by `transactionId` and need no second identifier alongside it.
359
+ - **Content-derived** — a hash of the file bytes. Interoperable with no issuer,
360
+ and it deduplicates for free, which first-writer-mints does not. It changes if a
361
+ file is re-rendered, and you need the bytes to compute it. Not unprecedented
362
+ here: `contracts[].signatures` already requires a `contractHash`.
363
+
364
+
365
+ ## Identifiers and `did:web`
366
+
367
+ Entity ids come from the `idFactory` you pass to `decompose`. The built-in
368
+ default is deterministic and derived from `transactionId`, which keeps repeated
369
+ runs stable and diffable, but it is a placeholder — supply your own to mint real
370
+ identifiers.
371
+
372
+ `did:web` is a natural fit, because the DID document is hosted rather than
373
+ anchored to a ledger and can carry service endpoints, so a party holding a
374
+ credential can resolve where to fetch what it refers to:
375
+
376
+ ```js
377
+ const entities = decomposeToV4(v3Transaction, {
378
+ idFactory: {
379
+ transaction: () => `did:web:moverly.com:transactions:${v3Transaction.transactionId}`,
380
+ person: (participant, index) =>
381
+ `did:web:moverly.com:transactions:${v3Transaction.transactionId}` +
382
+ `:participants:${participant?.participantId ?? index}`,
383
+ property: () => `urn:pdtf:property:${v3Transaction.transactionId}`,
384
+ title: (title, index) => `urn:pdtf:title:…`,
385
+ representation: (representative, representedParty) => `urn:pdtf:representation:…`,
386
+ sellerCapacity: (seller) => `urn:pdtf:sellercapacity:…`,
387
+ },
388
+ });
389
+ ```
390
+
391
+ `did:web:moverly.com:transactions:<id>` resolves to
392
+ `https://moverly.com/transactions/<id>/did.json`.
393
+
394
+ ### The DID pattern accepts colons, as the spec requires
395
+
396
+ A `did:web` encodes its path as colon-separated segments, and a port is
397
+ percent-encoded (`did:web:example.com%3A3000:…`). The W3C ABNF allows both —
398
+ `method-specific-id = *( *idchar ":" ) 1*idchar` — so the schema pattern follows
399
+ it exactly rather than approximating:
400
+
401
+ ```
402
+ ^did:[a-z0-9]+:(?:(?:[a-zA-Z0-9._-]|%[0-9A-Fa-f]{2})*:)*(?:[a-zA-Z0-9._-]|%[0-9A-Fa-f]{2})+$
403
+ ```
404
+
405
+ An earlier pattern here allowed no colons after the method name, which rejected
406
+ every `did:web` carrying a path — in nine places across five entities.
407
+
408
+ ### It does not disturb the round trip
409
+
410
+ `Transaction.id` is synthetic: `decompose` mints it and `recompose` drops it,
411
+ because V3 has no field for it. `transactionId` is the real V3 data and survives
412
+ untouched. So a `did:web` transaction id **embeds** the V3 identifier rather than
413
+ replacing it, both are present on the entity, and which DID method you choose
414
+ makes no difference to the V3 that comes back. There is a test asserting exactly
415
+ that.
416
+
417
+ `Property` and `Title` keep `urn:` ids: they are things described, not parties
418
+ that resolve to a DID document. Nothing stops you minting `did:web` for them —
419
+ the URN pattern already permits colons — but nothing requires it either.