@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.
- package/docs/ta6-certificate-permit-contract.md +51 -0
- package/docs/v4-mapping-and-recomposition.md +419 -0
- package/index.js +210 -0
- package/package.json +10 -1
- package/src/schemas/v3/combined.json +2261 -657
- package/src/schemas/v3/compactSkeleton.txt +120 -51
- package/src/schemas/v3/overlays/baspi4.json +54 -74
- package/src/schemas/v3/overlays/baspi5.json +54 -74
- package/src/schemas/v3/overlays/extensions/ac.json +2 -1
- package/src/schemas/v3/overlays/extensions/dk.json +3 -2
- package/src/schemas/v3/overlays/extensions/fd.json +2 -1
- package/src/schemas/v3/overlays/extensions/ic.json +44 -9
- package/src/schemas/v3/overlays/extensions/lc.json +3 -2
- package/src/schemas/v3/overlays/extensions/mi.json +2 -1
- package/src/schemas/v3/overlays/extensions/nd.json +3 -2
- package/src/schemas/v3/overlays/extensions/pc.json +3 -2
- package/src/schemas/v3/overlays/extensions/ph.json +2 -1
- package/src/schemas/v3/overlays/extensions/rw.json +4 -3
- package/src/schemas/v3/overlays/extensions/sc.json +8 -4
- package/src/schemas/v3/overlays/extensions/sd.json +3 -2
- package/src/schemas/v3/overlays/extensions/tr.json +3 -2
- package/src/schemas/v3/overlays/extensions/wg.json +2 -1
- package/src/schemas/v3/overlays/llc1.json +1 -2
- package/src/schemas/v3/overlays/lpe1.json +3 -2
- package/src/schemas/v3/overlays/nts.json +7 -3
- package/src/schemas/v3/overlays/nts2.json +9 -4
- package/src/schemas/v3/overlays/ntsl.json +5 -2
- package/src/schemas/v3/overlays/ntsl2.json +7 -3
- package/src/schemas/v3/overlays/piq.json +34 -25
- package/src/schemas/v3/overlays/rds.json +24 -50
- package/src/schemas/v3/overlays/sef25.json +16 -2
- package/src/schemas/v3/overlays/ta10.json +0 -9
- package/src/schemas/v3/overlays/ta6.json +39 -70
- package/src/schemas/v3/overlays/ta6ed6.json +331 -307
- package/{pensive-tesla-d0d355/src/schemas/v3/overlays/ta6.json → src/schemas/v3/overlays/ta6ed6v2.json} +1574 -1464
- package/src/schemas/v3/overlays/ta7.json +2 -1
- package/src/schemas/v3/overlays/ta7ed5.json +68 -21
- package/src/schemas/v3/pdtf-transaction.json +1030 -492
- package/src/schemas/v3/skeleton.json +163 -57
- package/src/schemas/v4/Gift.json +88 -0
- package/src/schemas/v4/Offer.json +425 -0
- package/src/schemas/v4/Organisation.json +167 -0
- package/src/schemas/v4/Person.json +321 -0
- package/src/schemas/v4/Property.json +27813 -0
- package/src/schemas/v4/Representation.json +67 -0
- package/src/schemas/v4/SellerCapacity.json +193 -0
- package/src/schemas/v4/Title.json +14660 -0
- package/src/schemas/v4/Transaction.json +1612 -0
- package/src/schemas/v4/TransactionRole.json +59 -0
- package/src/schemas/v4/mapping.json +1161 -0
- package/src/utils/checkTa6OmissionCompatibility.js +146 -0
- package/src/utils/extractExtensionOverlays.js +11 -2
- package/src/utils/extractOverlay.js +21 -0
- package/src/utils/generateV4Schemas.js +1358 -0
- package/src/utils/syncTa6ed6v2Markers.js +92 -0
- package/src/utils/v4.js +838 -0
- package/CLAUDE.md +0 -101
- package/pensive-tesla-d0d355/CLAUDE.md +0 -101
- package/pensive-tesla-d0d355/LICENSE +0 -21
- package/pensive-tesla-d0d355/README.md +0 -435
- package/pensive-tesla-d0d355/docs/branch-release-workflow.md +0 -138
- package/pensive-tesla-d0d355/docs/sef25-extensions-ui-spec.md +0 -355
- package/pensive-tesla-d0d355/index.js +0 -654
- package/pensive-tesla-d0d355/package-lock.json +0 -7297
- package/pensive-tesla-d0d355/package.json +0 -44
- package/pensive-tesla-d0d355/src/examples/v1/exampleAddParticipantVouch.json +0 -32
- package/pensive-tesla-d0d355/src/examples/v1/exampleDocumentedVouch.json +0 -40
- package/pensive-tesla-d0d355/src/examples/v1/exampleElectronicRecord.json +0 -30
- package/pensive-tesla-d0d355/src/examples/v1/exampleTransaction.json +0 -878
- package/pensive-tesla-d0d355/src/examples/v1/exampleVouch.json +0 -25
- package/pensive-tesla-d0d355/src/examples/v2/exampleTransaction.json +0 -901
- package/pensive-tesla-d0d355/src/examples/v3/exampleAddParticipantVouch.json +0 -38
- package/pensive-tesla-d0d355/src/examples/v3/exampleCustomOverlay.json +0 -55
- package/pensive-tesla-d0d355/src/examples/v3/exampleCustomOverlay2.json +0 -93
- package/pensive-tesla-d0d355/src/examples/v3/exampleDocumentedVouch.json +0 -47
- package/pensive-tesla-d0d355/src/examples/v3/exampleElectronicRecord.json +0 -36
- package/pensive-tesla-d0d355/src/examples/v3/exampleTransaction.json +0 -1038
- package/pensive-tesla-d0d355/src/examples/v3/exampleVouch.json +0 -32
- package/pensive-tesla-d0d355/src/mappings/SendPropertyDetailsRequestSchema.json +0 -1588
- package/pensive-tesla-d0d355/src/mappings/adf.js +0 -198
- package/pensive-tesla-d0d355/src/schemas/v1/GeoJSON.json +0 -1279
- package/pensive-tesla-d0d355/src/schemas/v1/baspiNotes.html +0 -102
- package/pensive-tesla-d0d355/src/schemas/v1/energy-performance-certificate.json +0 -124
- package/pensive-tesla-d0d355/src/schemas/v1/freehold-information.json +0 -2051
- package/pensive-tesla-d0d355/src/schemas/v1/leasehold-information.json +0 -2064
- package/pensive-tesla-d0d355/src/schemas/v1/legal-information.json +0 -1045
- package/pensive-tesla-d0d355/src/schemas/v1/material-facts.json +0 -2109
- package/pensive-tesla-d0d355/src/schemas/v1/pdtf-transaction-search-results.json +0 -40
- package/pensive-tesla-d0d355/src/schemas/v1/pdtf-transaction.json +0 -191
- package/pensive-tesla-d0d355/src/schemas/v1/searches/drainage-and-water.json +0 -261
- package/pensive-tesla-d0d355/src/schemas/v1/searches/local-land-charges.json +0 -69
- package/pensive-tesla-d0d355/src/schemas/v1/searches/local-searches-required.json +0 -857
- package/pensive-tesla-d0d355/src/schemas/v1/searches.json +0 -237
- package/pensive-tesla-d0d355/src/schemas/v1/title-deed.json +0 -841
- package/pensive-tesla-d0d355/src/schemas/v2/baspiNotes.html +0 -102
- package/pensive-tesla-d0d355/src/schemas/v2/combined.json +0 -37731
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/baspi.json +0 -8132
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/con29DW.json +0 -705
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/con29R.json +0 -2956
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/fme1.json +0 -1259
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/llc1.json +0 -25
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/lpe1.json +0 -2171
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/nts.json +0 -326
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/oc1.json +0 -2799
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/piq.json +0 -3419
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/rds.json +0 -3736
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/ta10.json +0 -5306
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/ta6.json +0 -4533
- package/pensive-tesla-d0d355/src/schemas/v2/overlays/ta7.json +0 -1045
- package/pensive-tesla-d0d355/src/schemas/v2/pdtf-transaction.json +0 -34351
- package/pensive-tesla-d0d355/src/schemas/v2/skeleton.json +0 -3973
- package/pensive-tesla-d0d355/src/schemas/v3/combined.json +0 -48469
- package/pensive-tesla-d0d355/src/schemas/v3/compactSkeleton.txt +0 -4519
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/README.md +0 -299
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/baspi4.json +0 -8025
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/baspi5.json +0 -8175
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/con29DW.json +0 -716
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/con29R.json +0 -2981
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ac.json +0 -333
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/as.json +0 -74
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/dk.json +0 -32
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/dr.json +0 -73
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/er.json +0 -85
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/fd.json +0 -75
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/hi.json +0 -51
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/hs.json +0 -73
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ic.json +0 -59
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/jk.json +0 -96
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/la.json +0 -85
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/lc.json +0 -75
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ma.json +0 -90
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/mc.json +0 -38
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/mi.json +0 -69
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/nd.json +0 -43
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/oa.json +0 -24
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/oc.json +0 -57
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/pc.json +0 -37
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ph.json +0 -194
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/rw.json +0 -68
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sb.json +0 -73
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sc.json +0 -129
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sd.json +0 -63
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sf.json +0 -74
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/sl.json +0 -68
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/ta.json +0 -252
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/tf.json +0 -103
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/tr.json +0 -73
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/extensions/wg.json +0 -350
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/fme1.json +0 -1266
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/llc1.json +0 -25
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/lpe1.json +0 -2199
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/nts.json +0 -2157
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/nts2.json +0 -2742
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/ntsl.json +0 -1813
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/ntsl2.json +0 -2217
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/oc1.json +0 -2817
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/piq.json +0 -3322
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/rds.json +0 -3665
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/sef25.json +0 -1639
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/sr24.json +0 -38
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/ta10.json +0 -4654
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/ta6ed6.json +0 -6534
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/ta7.json +0 -1537
- package/pensive-tesla-d0d355/src/schemas/v3/overlays/ta7ed5.json +0 -1551
- package/pensive-tesla-d0d355/src/schemas/v3/pdtf-transaction.json +0 -41573
- package/pensive-tesla-d0d355/src/schemas/v3/skeleton.json +0 -5767
- package/pensive-tesla-d0d355/src/schemas/verifiedClaims/README.md +0 -21
- package/pensive-tesla-d0d355/src/schemas/verifiedClaims/pdtf-verified-claims.json +0 -447
- package/pensive-tesla-d0d355/src/utils/compactSkeleton.js +0 -84
- package/pensive-tesla-d0d355/src/utils/countTokens.js +0 -67
- package/pensive-tesla-d0d355/src/utils/extractExtensionOverlays.js +0 -624
- package/pensive-tesla-d0d355/src/utils/extractOverlay.js +0 -381
- package/pensive-tesla-d0d355/src/utils/extractProperties.js +0 -44
- package/pensive-tesla-d0d355/src/utils/listPathTitles.js +0 -36
- package/pensive-tesla-d0d355/src/utils/minimalSkeleton.js +0 -85
- package/pensive-tesla-d0d355/src/utils/pathList.csv +0 -903
- package/pensive-tesla-d0d355/src/utils/pathSkeleton.js +0 -181
- package/src/examples/v1/exampleAddParticipantVouch.json +0 -32
- package/src/examples/v1/exampleDocumentedVouch.json +0 -40
- package/src/examples/v1/exampleElectronicRecord.json +0 -30
- package/src/examples/v1/exampleTransaction.json +0 -878
- package/src/examples/v1/exampleVouch.json +0 -25
- package/src/examples/v2/exampleTransaction.json +0 -901
- package/src/examples/v3/exampleAddParticipantVouch.json +0 -38
- package/src/examples/v3/exampleCustomOverlay.json +0 -55
- package/src/examples/v3/exampleCustomOverlay2.json +0 -93
- package/src/examples/v3/exampleDocumentedVouch.json +0 -47
- package/src/examples/v3/exampleElectronicRecord.json +0 -36
- package/src/examples/v3/exampleTransaction.json +0 -1038
- package/src/examples/v3/exampleVouch.json +0 -32
- package/src/mappings/SendPropertyDetailsRequestSchema.json +0 -1588
- 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.
|