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