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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,416 @@
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. A `Title` carrying only keys shared by both source arrays recomposes into
143
+ `titlesToBeSold` only, not into `ownershipsToBeTransferred`.
144
+ 3. **`ownershipsToBeTransferred` order is canonicalised** to `titlesToBeSold`
145
+ order. The array is correlated by `titleNumber` and its order carries no
146
+ meaning in V3, so this is a deliberate normalisation that removes the
147
+ index-divergence class of bug rather than reproducing it. Entry *content* is
148
+ preserved exactly.
149
+
150
+ 4. **An explicitly empty container recomposes as absent** — `propertyPack {}`,
151
+ `propertyPack.ownership {}` and `propertyPack.titlesToBeSold []` are all valid
152
+ V3 but carry no data, and the entity model has no place to record "present but
153
+ empty". (`participants []` is invalid V3 — `minItems: 1` — so it does not
154
+ arise.)
155
+ 5. **`recompose` always emits `$schema`**, set to `source.v3SchemaId`. An
156
+ instance that omitted one gains it.
157
+
158
+ Everything else round-trips to deep equality, including the full example
159
+ transaction in `src/examples/v3/exampleTransaction.json`.
160
+
161
+ ## What the round trip is actually tested against
162
+
163
+ Worth being precise about, because "it round-trips" is easy to overclaim:
164
+
165
+ | | |
166
+ | --- | --- |
167
+ | `exampleTransaction.json` | One real instance — roughly **12% of the V3 leaf-path surface** (456 of ~3,300 paths) |
168
+ | Synthetic awkward instance | The structural transforms: two-array merge, index divergence, one-sided titles |
169
+ | `roundTripEdges.test.js` | The six top-level keys the example never touches, plus the boundaries below |
170
+
171
+ The untested majority is `propertyPack` subtree, which passes through wholesale
172
+ under a single prefix rule — low risk, but not zero. The transforms that could
173
+ actually lose data (title merge/split, participant split, ownership split) are
174
+ covered directly.
175
+
176
+ **Refused rather than guessed:** a `titleNumber` repeated within one source
177
+ array throws. It is valid V3 — neither array declares uniqueness — but a title
178
+ number identifies a title, so merging the two entries would silently drop one.
179
+
180
+ **Not yet done:** generating fixtures from the schema to exercise the remaining
181
+ ~88%, and round-tripping real production transactions. Either would turn "no
182
+ known failures" into something stronger.
183
+
184
+ ## Defects this surfaced in the generated schemas
185
+
186
+ Two fields were being lost because the generator read only `node.properties` and
187
+ ignored V3's `discriminator` + `oneOf` branches:
188
+
189
+ - **`Title` was missing every tenure detail** — `leaseholdInformation`,
190
+ `managedFreeholdOrCommonholdInformation`, `estateRentcharges`,
191
+ `wholeFreeholdForSale`, `otherOwnershipDetails` all live on
192
+ `ownershipsToBeTransferred` branches.
193
+ - **`Person`/`SellerCapacity` were missing** `sellersCapacity`,
194
+ `dateBecameOwnerOrAuthority` and `offerId`, which live on `participants`
195
+ branches. `SellerCapacity` was hand-authored with a four-value `capacityType`
196
+ enum against V3's nine-value `capacity`.
197
+
198
+ `mergedProperties()` now folds branch properties in, and `SellerCapacity` is
199
+ derived from the V3 seller branch rather than hand-authored.
200
+
201
+
202
+ ## Representation
203
+
204
+ V3 previously referred to people **by role only** (`originatorRole`,
205
+ `destinationRole` on enquiry messages) and nothing anywhere referenced a
206
+ specific participant. That made a representation relationship underivable — you
207
+ could see that a transaction had a "Seller's Conveyancer", but not which seller
208
+ they acted for — and it made two sellers with separate conveyancers
209
+ unrepresentable, because both participants would carry the same role with
210
+ nothing to tell them apart.
211
+
212
+ Two additive, optional fields close that gap:
213
+
214
+ | Field | Purpose |
215
+ | --- | --- |
216
+ | `participants[].participantId` | Stable identity within the transaction |
217
+ | `participants[].actingFor` | The `participantId`s this participant is instructed by |
218
+
219
+ Both follow the existing key idiom (`^[A-Za-z0-9][A-Za-z0-9._:+-]*$`, the same
220
+ pattern `offers` and `enquiries` keys use). Neither carries a form `*Ref`, so no
221
+ overlay changes, and both are optional, so existing instances stay valid.
222
+
223
+ ### Cardinality
224
+
225
+ One `Representation` per **(representative, represented party)** pair.
226
+
227
+ - A conveyancer instructed jointly by two sellers → `actingFor: ["s1", "s2"]` →
228
+ **two** Representations. Each is a separate statement that can be presented,
229
+ or revoked, on its own.
230
+ - A separated couple instructing their own conveyancers → two representatives,
231
+ each `actingFor` one seller → **one each**. This is the case the old
232
+ role-only model could not express at all.
233
+
234
+ ### A conveyancer is instructed by a person, not by an offer
235
+
236
+ `actingFor` references participants. The retainer is with the client, so a
237
+ buyer's conveyancer acts for the buyer; `participants[].offerId` remains the
238
+ separate link from that buyer to their offer. This also means a **prospective
239
+ buyer**, who has no `offerId` until they make an offer, can still be
240
+ represented — which a buyer-via-offer model could not do.
241
+
242
+ ### The relationship credentials
243
+
244
+ `Representation`, `SellerCapacity`, `Offer`, `Gift` and `TransactionRole` are
245
+ `kind: "credential"`. They are the linking entities that **embody a party's
246
+ role**, and role is stored nowhere else.
247
+
248
+ | Credential | Asserts | Role |
249
+ | --- | --- | --- |
250
+ | `SellerCapacity` | this person sells, in this capacity | **implied**: Seller |
251
+ | `Offer` | this person made this offer | **implied**: Buyer |
252
+ | `Gift` | this person gifts funds towards a purchase | **implied**: Gift Donor |
253
+ | `Representation` | this party is instructed by that party | explicit — which kind |
254
+ | `TransactionRole` | this party takes this role | explicit — which role |
255
+
256
+ For the first three, role is implied 1:1 by the credential's existence: V3 puts
257
+ `sellersCapacity` only on the Seller branch and `giftDetails` only on Gift Donor.
258
+ `offerId` is on the Buyer **and** Gift Donor branches, so a donor's offer link
259
+ lives on `Gift` rather than `Offer`, which keeps `Offer ⇒ Buyer` exact.
260
+
261
+ `Representation` and `TransactionRole` each span several roles, so they carry
262
+ role as their own discriminator. That is not a duplicated participant attribute:
263
+ "what kind of representation is this" is a property of the representation, and
264
+ asserting a role is `TransactionRole`'s entire purpose. Representation's role
265
+ cannot be derived from the edge — acting for a seller tells you the side, not
266
+ whether the party is the conveyancer, the agent or the surveyor.
267
+
268
+ ### Identity: `participants[].did`
269
+
270
+ V3 participants carry a `did` — the party's own identifier, minted when the
271
+ party is created, so in practice always present. V4 uses it verbatim as
272
+ `Person.id`; the `idFactory` mints one only for instances that carry none.
273
+
274
+ That matters because identity must be stable *across* transactions. A person
275
+ selling one property and buying another appears in two transactions, and should
276
+ be the same subject in both — one wallet, accumulating credentials, not one
277
+ identity per participation. A transaction-scoped id (`did:web:…:transactions:
278
+ <txId>:participants:<pid>`) cannot do that; `did:key` minted per person can.
279
+
280
+ `participantId` remains as the transaction-local fallback for implementations
281
+ that do not mint DIDs. `actingFor` names a party by `did` where one exists and
282
+ by `participantId` otherwise.
283
+
284
+ **One party, one entry.** A `did` repeated within a transaction is the same
285
+ party listed twice, which `decompose` refuses: a transaction is a single sale,
286
+ so nobody is both its buyer and its seller. The same person across a sale and an
287
+ onward purchase is two transactions, each with its own credentials, and needs
288
+ nothing special.
289
+
290
+ ### Why role is not on the participant
291
+
292
+ Because a second copy would not be revoked.
293
+
294
+ Firing a conveyancer means revoking their `Representation`. If the transaction
295
+ also recorded `role: "Seller's Conveyancer"` against that participant, the
296
+ revocation would remove the relationship and leave the role assertion standing —
297
+ two sources of truth, disagreeing, with the stale one still readable.
298
+
299
+ So `Transaction.participants[]` is a **roster**, carrying only what remains true
300
+ of a party regardless of any relationship:
301
+
302
+ ```jsonc
303
+ { "participant": "did:pdtf:person-tx-c1",
304
+ "participantId": "c1",
305
+ "organisation": "Suemme and Profitt",
306
+ "organisationReference": "R1" }
307
+ ```
308
+
309
+ `organisation` stays here rather than on the credential for the same
310
+ single-home reason: where someone works does not stop being true when a
311
+ representation ends. A credential names its parties by DID, so resolving the
312
+ representative gives the firm.
313
+
314
+ ### They are part of the round trip
315
+
316
+ Because role and every relationship live only on the credentials, `recompose`
317
+ needs them — they are not redundant copies. `decompose` therefore returns them
318
+ alongside the other entities:
319
+
320
+ ```js
321
+ const { decomposeToV4, recomposeFromV4 } = require("@pdtf/schemas");
322
+
323
+ const entities = decomposeToV4(v3Transaction);
324
+ // { Property, Title, Transaction, Person,
325
+ // Representation, SellerCapacity, Offer, Gift, TransactionRole }
326
+
327
+ recomposeFromV4(entities); // === v3Transaction
328
+ ```
329
+
330
+ Dropping a credential and recomposing gives back a V3 instance in which that
331
+ party has no role and no relationship — which is exactly what revocation should
332
+ mean, and is asserted by a test.
333
+
334
+ Every participant carrying a role receives exactly one role-bearing credential:
335
+ the specific one where the relationship exists, `TransactionRole` otherwise. A
336
+ participant with no role receives none.
337
+
338
+ `SellerCapacity` is scoped to the transaction rather than to a title, since the
339
+ transaction already carries the titles to be sold. It is emitted for **every**
340
+ seller, whether or not a capacity has been declared, so the credential embodying
341
+ the Seller role is never missing while a form is still being filled in.
342
+
343
+ ### If you want an identifier everyone can mint
344
+
345
+ Three patterns, of which V3 already uses two:
346
+
347
+ - **First-writer-mints** — whoever adds the item sets the canonical id, and
348
+ everyone else adopts it because it is in the shared payload. What the seven new
349
+ identifiers assume. A source can simply use its own id as the value, promoting
350
+ it out of its private namespace. Its failure mode is duplication, not ambiguity:
351
+ two parties adding the same document independently mint two ids.
352
+ - **Authority-issued** — `uprn`, `titleNumber`, `lmkKey` and `hmlrReference`
353
+ already work this way. It needs a registry and one authoritative issuer, which
354
+ exists for properties, titles and EPCs but not for documents. Transactions are
355
+ identified by `transactionId` and need no second identifier alongside it.
356
+ - **Content-derived** — a hash of the file bytes. Interoperable with no issuer,
357
+ and it deduplicates for free, which first-writer-mints does not. It changes if a
358
+ file is re-rendered, and you need the bytes to compute it. Not unprecedented
359
+ here: `contracts[].signatures` already requires a `contractHash`.
360
+
361
+
362
+ ## Identifiers and `did:web`
363
+
364
+ Entity ids come from the `idFactory` you pass to `decompose`. The built-in
365
+ default is deterministic and derived from `transactionId`, which keeps repeated
366
+ runs stable and diffable, but it is a placeholder — supply your own to mint real
367
+ identifiers.
368
+
369
+ `did:web` is a natural fit, because the DID document is hosted rather than
370
+ anchored to a ledger and can carry service endpoints, so a party holding a
371
+ credential can resolve where to fetch what it refers to:
372
+
373
+ ```js
374
+ const entities = decomposeToV4(v3Transaction, {
375
+ idFactory: {
376
+ transaction: () => `did:web:moverly.com:transactions:${v3Transaction.transactionId}`,
377
+ person: (participant, index) =>
378
+ `did:web:moverly.com:transactions:${v3Transaction.transactionId}` +
379
+ `:participants:${participant?.participantId ?? index}`,
380
+ property: () => `urn:pdtf:property:${v3Transaction.transactionId}`,
381
+ title: (title, index) => `urn:pdtf:title:…`,
382
+ representation: (representative, representedParty) => `urn:pdtf:representation:…`,
383
+ sellerCapacity: (seller) => `urn:pdtf:sellercapacity:…`,
384
+ },
385
+ });
386
+ ```
387
+
388
+ `did:web:moverly.com:transactions:<id>` resolves to
389
+ `https://moverly.com/transactions/<id>/did.json`.
390
+
391
+ ### The DID pattern accepts colons, as the spec requires
392
+
393
+ A `did:web` encodes its path as colon-separated segments, and a port is
394
+ percent-encoded (`did:web:example.com%3A3000:…`). The W3C ABNF allows both —
395
+ `method-specific-id = *( *idchar ":" ) 1*idchar` — so the schema pattern follows
396
+ it exactly rather than approximating:
397
+
398
+ ```
399
+ ^did:[a-z0-9]+:(?:(?:[a-zA-Z0-9._-]|%[0-9A-Fa-f]{2})*:)*(?:[a-zA-Z0-9._-]|%[0-9A-Fa-f]{2})+$
400
+ ```
401
+
402
+ An earlier pattern here allowed no colons after the method name, which rejected
403
+ every `did:web` carrying a path — in nine places across five entities.
404
+
405
+ ### It does not disturb the round trip
406
+
407
+ `Transaction.id` is synthetic: `decompose` mints it and `recompose` drops it,
408
+ because V3 has no field for it. `transactionId` is the real V3 data and survives
409
+ untouched. So a `did:web` transaction id **embeds** the V3 identifier rather than
410
+ replacing it, both are present on the entity, and which DID method you choose
411
+ makes no difference to the V3 that comes back. There is a test asserting exactly
412
+ that.
413
+
414
+ `Property` and `Title` keep `urn:` ids: they are things described, not parties
415
+ that resolve to a DID document. Nothing stops you minting `did:web` for them —
416
+ the URN pattern already permits colons — but nothing requires it either.
package/index.js CHANGED
@@ -821,6 +821,16 @@ const setCacheMaxSize = (maxSize) => {
821
821
  return keysToDelete.length;
822
822
  };
823
823
 
824
+ const {
825
+ mapping: v4Mapping,
826
+ decompose: decomposeToV4,
827
+ recompose: recomposeFromV4,
828
+ arrayKeyFor: v4ArrayKeyFor,
829
+ identifyV3Pointer,
830
+ resolveV3Pointer,
831
+ resolveV3PointerAll,
832
+ } = require("./src/utils/v4");
833
+
824
834
  module.exports = {
825
835
  ajv,
826
836
  getTransactionSchema,
@@ -843,4 +853,12 @@ module.exports = {
843
853
  warmupCache,
844
854
  pruneCacheByAge,
845
855
  setCacheMaxSize,
856
+ // V3 <-> V4 entity decomposition (see src/schemas/v4/mapping.json)
857
+ v4Mapping,
858
+ decomposeToV4,
859
+ recomposeFromV4,
860
+ v4ArrayKeyFor,
861
+ identifyV3Pointer,
862
+ resolveV3Pointer,
863
+ resolveV3PointerAll,
846
864
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdtf/schemas",
3
- "version": "3.6.0-dev.19",
3
+ "version": "3.6.0-dev.21",
4
4
  "description": "Property Data Trust Framework Schemas and Utilities",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -19,6 +19,7 @@
19
19
  "test:watch": "jest --watch",
20
20
  "extract-overlays": "cd src/utils && node extractOverlay.js",
21
21
  "extract-extension-overlays": "cd src/utils && node extractExtensionOverlays.js",
22
+ "generate:v4": "node src/utils/generateV4Schemas.js",
22
23
  "publish:dev": "npm publish --tag dev",
23
24
  "publish:next": "npm publish --tag next",
24
25
  "publish:latest": "npm publish --tag latest"