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

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.
@@ -139,8 +139,11 @@ position that could diverge from another producer's.
139
139
 
140
140
  1. **Overlay metadata** (`*Ref`, `*Required` keys) is stripped from V4. It is
141
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`.
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.
144
147
  3. **`ownershipsToBeTransferred` order is canonicalised** to `titlesToBeSold`
145
148
  order. The array is correlated by `titleNumber` and its order carries no
146
149
  meaning in V3, so this is a deliberate normalisation that removes the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdtf/schemas",
3
- "version": "3.6.0-dev.21",
3
+ "version": "3.6.0-dev.22",
4
4
  "description": "Property Data Trust Framework Schemas and Utilities",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -6,7 +6,7 @@
6
6
  "description": "A gift of funds towards a purchase, made by a gift donor. Implies the Gift Donor role.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -6,7 +6,7 @@
6
6
  "description": "An offer made by a prospective buyer in a property transaction.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -6,7 +6,7 @@
6
6
  "description": "A legal entity (company, firm, partnership) participating in a property transaction.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -6,7 +6,7 @@
6
6
  "description": "A natural person participating in a property transaction — name, contact details, address, verification status.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -6,7 +6,7 @@
6
6
  "description": "Physical and environmental facts about a property — address, construction, fixtures, services, searches, documents. Excludes ownership, titles, and participants.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -6,7 +6,7 @@
6
6
  "description": "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.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -6,7 +6,7 @@
6
6
  "description": "The legal capacity in which a seller is selling a given title. Derived from the V3 participants 'Seller' branch (sellersCapacity, dateBecameOwnerOrAuthority).",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -6,7 +6,7 @@
6
6
  "description": "HM Land Registry title — register extract, tenure type, and legal interest details for a single title number.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -6,7 +6,7 @@
6
6
  "description": "Transaction-level metadata — status, milestones, chain, contracts, offers, enquiries, and sale context.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -1396,7 +1396,7 @@
1396
1396
  },
1397
1397
  "titlesToBeSold": {
1398
1398
  "title": "Titles to be Sold",
1399
- "description": "Array of URNs referencing Title entities to be sold in this transaction",
1399
+ "description": "Ordered URNs of the Title entities that V3 listed in propertyPack.titlesToBeSold. A title known only from propertyPack.ownership.ownershipsToBeTransferred is a Title entity but is NOT listed here, so recomposition can tell the two apart and rebuild each source array faithfully.",
1400
1400
  "type": "array",
1401
1401
  "minItems": 1,
1402
1402
  "items": {
@@ -6,7 +6,7 @@
6
6
  "description": "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.",
7
7
  "x-pdtf-source": {
8
8
  "packageName": "@pdtf/schemas",
9
- "packageVersion": "3.6.0-dev.21",
9
+ "packageVersion": "3.6.0-dev.22",
10
10
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
11
11
  "combinedPath": "src/schemas/v3/combined.json",
12
12
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -5,7 +5,7 @@
5
5
  "generator": "src/utils/generateV4Schemas.js",
6
6
  "source": {
7
7
  "packageName": "@pdtf/schemas",
8
- "packageVersion": "3.6.0-dev.21",
8
+ "packageVersion": "3.6.0-dev.22",
9
9
  "v3SchemaId": "https://trust.propdata.org.uk/schemas/v3/pdtf-transaction.json",
10
10
  "combinedPath": "src/schemas/v3/combined.json",
11
11
  "combinedSha256": "1c6c5812f73a36d3674d5fb7b5eb1f6ce524d09c4195e281e078b9e41ea94f6d"
@@ -93,6 +93,13 @@
93
93
  "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."
94
94
  ]
95
95
  },
96
+ "extensionPolicy": {
97
+ "description": "What each rule does with instance keys the V3 schema does not define — platform extensions such as an externalIds namespace or a vendor's own block. `passthrough` carries them into the entity and back out again on recomposition; `drop` discards them. Exactly one rule per V3 node is the sink, so an unknown key has a single, predictable home. verifyCoverage() guarantees every V3 SCHEMA key has a V4 home; this is the answer for everything outside the schema.",
98
+ "values": [
99
+ "passthrough",
100
+ "drop"
101
+ ]
102
+ },
96
103
  "arrayKeys": [
97
104
  {
98
105
  "pointer": "/participants",
@@ -883,6 +890,7 @@
883
890
  "covenantAnalysis"
884
891
  ]
885
892
  },
893
+ "extensions": "passthrough",
886
894
  "instance": {
887
895
  "cardinality": "many",
888
896
  "orderedBy": {
@@ -892,7 +900,7 @@
892
900
  "correlateBy": "titleNumber",
893
901
  "primarySource": true
894
902
  },
895
- "notes": "Primary source: a Title carrying only shared keys recomposes to this array."
903
+ "notes": "Primary source: it takes any key the V3 schema does not define, and recomposition emits one entry per id in Transaction.titlesToBeSold."
896
904
  },
897
905
  {
898
906
  "id": "title.ownershipsToBeTransferred",
@@ -913,6 +921,7 @@
913
921
  "otherOwnershipDetails"
914
922
  ]
915
923
  },
924
+ "extensions": "drop",
916
925
  "instance": {
917
926
  "cardinality": "many",
918
927
  "orderedBy": {
@@ -944,6 +953,7 @@
944
953
  "ownershipsToBeTransferred"
945
954
  ]
946
955
  },
956
+ "extensions": "passthrough",
947
957
  "instance": {
948
958
  "cardinality": "single"
949
959
  }
@@ -961,6 +971,7 @@
961
971
  "legalOwners"
962
972
  ]
963
973
  },
974
+ "extensions": "passthrough",
964
975
  "instance": {
965
976
  "cardinality": "single"
966
977
  }
@@ -979,6 +990,7 @@
979
990
  "organisationReference"
980
991
  ]
981
992
  },
993
+ "extensions": "drop",
982
994
  "instance": {
983
995
  "cardinality": "many",
984
996
  "orderedBy": {
@@ -990,6 +1002,7 @@
990
1002
  },
991
1003
  {
992
1004
  "id": "credential.sellerCapacity",
1005
+ "extensions": "drop",
993
1006
  "v3Pointer": "/participants/{index}",
994
1007
  "entity": "SellerCapacity",
995
1008
  "entityPointer": "",
@@ -1008,6 +1021,7 @@
1008
1021
  },
1009
1022
  {
1010
1023
  "id": "credential.offer",
1024
+ "extensions": "drop",
1011
1025
  "v3Pointer": "/participants/{index}",
1012
1026
  "entity": "Offer",
1013
1027
  "entityPointer": "",
@@ -1025,6 +1039,7 @@
1025
1039
  },
1026
1040
  {
1027
1041
  "id": "credential.gift",
1042
+ "extensions": "drop",
1028
1043
  "v3Pointer": "/participants/{index}",
1029
1044
  "entity": "Gift",
1030
1045
  "entityPointer": "",
@@ -1043,6 +1058,7 @@
1043
1058
  },
1044
1059
  {
1045
1060
  "id": "credential.representation",
1061
+ "extensions": "drop",
1046
1062
  "v3Pointer": "/participants/{index}",
1047
1063
  "entity": "Representation",
1048
1064
  "entityPointer": "",
@@ -1065,6 +1081,7 @@
1065
1081
  },
1066
1082
  {
1067
1083
  "id": "credential.transactionRole",
1084
+ "extensions": "drop",
1068
1085
  "v3Pointer": "/participants/{index}",
1069
1086
  "entity": "TransactionRole",
1070
1087
  "entityPointer": "",
@@ -1099,6 +1116,7 @@
1099
1116
  "verification"
1100
1117
  ]
1101
1118
  },
1119
+ "extensions": "passthrough",
1102
1120
  "instance": {
1103
1121
  "cardinality": "many",
1104
1122
  "orderedBy": {
@@ -1121,6 +1139,7 @@
1121
1139
  "$schema"
1122
1140
  ]
1123
1141
  },
1142
+ "extensions": "passthrough",
1124
1143
  "instance": {
1125
1144
  "cardinality": "single"
1126
1145
  }
@@ -398,7 +398,7 @@ const generateTransaction = () => {
398
398
  topLevel.titlesToBeSold = {
399
399
  title: "Titles to be Sold",
400
400
  description:
401
- "Array of URNs referencing Title entities to be sold in this transaction",
401
+ "Ordered URNs of the Title entities that V3 listed in propertyPack.titlesToBeSold. A title known only from propertyPack.ownership.ownershipsToBeTransferred is a Title entity but is NOT listed here, so recomposition can tell the two apart and rebuild each source array faithfully.",
402
402
  type: "array",
403
403
  minItems: 1,
404
404
  items: urnRef("Title URN", "URN reference to a Title entity"),
@@ -973,6 +973,7 @@ const buildRules = () => [
973
973
  entity: "Title",
974
974
  entityPointer: "",
975
975
  keys: { mode: "include", values: titlesKeys },
976
+ extensions: "passthrough",
976
977
  instance: {
977
978
  cardinality: "many",
978
979
  orderedBy: { entity: "Transaction", pointer: "/titlesToBeSold" },
@@ -980,7 +981,7 @@ const buildRules = () => [
980
981
  primarySource: true,
981
982
  },
982
983
  notes:
983
- "Primary source: a Title carrying only shared keys recomposes to this array.",
984
+ "Primary source: it takes any key the V3 schema does not define, and recomposition emits one entry per id in Transaction.titlesToBeSold.",
984
985
  },
985
986
  {
986
987
  id: "title.ownershipsToBeTransferred",
@@ -988,6 +989,7 @@ const buildRules = () => [
988
989
  entity: "Title",
989
990
  entityPointer: "",
990
991
  keys: { mode: "include", values: ownershipItemKeys },
992
+ extensions: "drop",
991
993
  instance: {
992
994
  cardinality: "many",
993
995
  orderedBy: { entity: "Transaction", pointer: "/titlesToBeSold" },
@@ -1012,6 +1014,7 @@ const buildRules = () => [
1012
1014
  entity: "Transaction",
1013
1015
  entityPointer: "/saleContext",
1014
1016
  keys: { mode: "exclude", values: ["ownershipsToBeTransferred"] },
1017
+ extensions: "passthrough",
1015
1018
  instance: { cardinality: "single" },
1016
1019
  },
1017
1020
 
@@ -1022,6 +1025,7 @@ const buildRules = () => [
1022
1025
  entity: "Property",
1023
1026
  entityPointer: "",
1024
1027
  keys: { mode: "exclude", values: PROPERTY_EXCLUDE },
1028
+ extensions: "passthrough",
1025
1029
  instance: { cardinality: "single" },
1026
1030
  },
1027
1031
 
@@ -1032,6 +1036,7 @@ const buildRules = () => [
1032
1036
  entity: "Transaction",
1033
1037
  entityPointer: "/participants/{index}",
1034
1038
  keys: { mode: "include", values: PARTICIPANT_ROSTER_FIELDS },
1039
+ extensions: "drop",
1035
1040
  instance: {
1036
1041
  cardinality: "many",
1037
1042
  orderedBy: { entity: "Transaction", pointer: "/participants" },
@@ -1046,6 +1051,7 @@ const buildRules = () => [
1046
1051
  // returns the candidates; pass the decomposed entities to narrow it.
1047
1052
  {
1048
1053
  id: "credential.sellerCapacity",
1054
+ extensions: "drop",
1049
1055
  v3Pointer: "/participants/{index}",
1050
1056
  entity: "SellerCapacity",
1051
1057
  entityPointer: "",
@@ -1055,6 +1061,7 @@ const buildRules = () => [
1055
1061
  },
1056
1062
  {
1057
1063
  id: "credential.offer",
1064
+ extensions: "drop",
1058
1065
  v3Pointer: "/participants/{index}",
1059
1066
  entity: "Offer",
1060
1067
  entityPointer: "",
@@ -1064,6 +1071,7 @@ const buildRules = () => [
1064
1071
  },
1065
1072
  {
1066
1073
  id: "credential.gift",
1074
+ extensions: "drop",
1067
1075
  v3Pointer: "/participants/{index}",
1068
1076
  entity: "Gift",
1069
1077
  entityPointer: "",
@@ -1073,6 +1081,7 @@ const buildRules = () => [
1073
1081
  },
1074
1082
  {
1075
1083
  id: "credential.representation",
1084
+ extensions: "drop",
1076
1085
  v3Pointer: "/participants/{index}",
1077
1086
  entity: "Representation",
1078
1087
  entityPointer: "",
@@ -1084,6 +1093,7 @@ const buildRules = () => [
1084
1093
  },
1085
1094
  {
1086
1095
  id: "credential.transactionRole",
1096
+ extensions: "drop",
1087
1097
  v3Pointer: "/participants/{index}",
1088
1098
  entity: "TransactionRole",
1089
1099
  entityPointer: "",
@@ -1098,6 +1108,7 @@ const buildRules = () => [
1098
1108
  entity: "Person",
1099
1109
  entityPointer: "",
1100
1110
  keys: { mode: "include", values: personKeys },
1111
+ extensions: "passthrough",
1101
1112
  instance: {
1102
1113
  cardinality: "many",
1103
1114
  orderedBy: {
@@ -1115,6 +1126,7 @@ const buildRules = () => [
1115
1126
  entity: "Transaction",
1116
1127
  entityPointer: "",
1117
1128
  keys: { mode: "exclude", values: [...TRANSACTION_EXCLUDE, "$schema"] },
1129
+ extensions: "passthrough",
1118
1130
  instance: { cardinality: "single" },
1119
1131
  },
1120
1132
  ];
@@ -1236,6 +1248,11 @@ const mapping = {
1236
1248
  "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
1249
  ],
1238
1250
  },
1251
+ extensionPolicy: {
1252
+ description:
1253
+ "What each rule does with instance keys the V3 schema does not define — platform extensions such as an externalIds namespace or a vendor's own block. `passthrough` carries them into the entity and back out again on recomposition; `drop` discards them. Exactly one rule per V3 node is the sink, so an unknown key has a single, predictable home. verifyCoverage() guarantees every V3 SCHEMA key has a V4 home; this is the answer for everything outside the schema.",
1254
+ values: ["passthrough", "drop"],
1255
+ },
1239
1256
  arrayKeys: arrayKeys.keyed,
1240
1257
  arrayKeyScopes: {
1241
1258
  canonical:
package/src/utils/v4.js CHANGED
@@ -46,12 +46,69 @@ const omit = (obj, keys) =>
46
46
  Object.entries(obj || {}).filter(([k]) => !keys.includes(k))
47
47
  );
48
48
 
49
- /** Apply a rule's `keys` selector to a V3 node. */
49
+ /**
50
+ * Every key any rule at this V3 pointer claims by name. Anything outside it is
51
+ * an extension: a platform's own block, or an externalIds namespace the schema
52
+ * does not define.
53
+ */
54
+ const claimedKeysAt = (v3Pointer) => {
55
+ const claimed = new Set();
56
+ for (const rule of mapping.rules) {
57
+ if (rule.v3Pointer !== v3Pointer || rule.keys?.mode !== "include") continue;
58
+ for (const key of rule.keys.values) claimed.add(key);
59
+ }
60
+ return claimed;
61
+ };
62
+
63
+ const CLAIMED_BY_POINTER = new Map();
64
+ const claimedFor = (v3Pointer) => {
65
+ if (!CLAIMED_BY_POINTER.has(v3Pointer)) {
66
+ CLAIMED_BY_POINTER.set(v3Pointer, claimedKeysAt(v3Pointer));
67
+ }
68
+ return CLAIMED_BY_POINTER.get(v3Pointer);
69
+ };
70
+
71
+ /**
72
+ * Apply a rule's `keys` selector to a V3 node.
73
+ *
74
+ * An exclude-mode rule takes everything it does not exclude, extensions
75
+ * included. An include-mode rule names its keys, so extensions would fall
76
+ * through the gap — which is why each V3 node nominates exactly one rule as its
77
+ * extension sink (`extensions: "passthrough"` in the manifest). Without that,
78
+ * whether a platform's own keys survived depended on which mode happened to
79
+ * claim their parent.
80
+ */
50
81
  const selectKeys = (node, rule) => {
51
82
  if (!rule.keys) return { ...(node || {}) };
52
- return rule.keys.mode === "include"
53
- ? pick(node, rule.keys.values)
54
- : omit(node, rule.keys.values);
83
+ if (rule.keys.mode === "exclude") return omit(node, rule.keys.values);
84
+
85
+ const selected = pick(node, rule.keys.values);
86
+ if (rule.extensions === "passthrough") {
87
+ const claimed = claimedFor(rule.v3Pointer);
88
+ for (const [key, value] of Object.entries(node || {})) {
89
+ if (!claimed.has(key)) selected[key] = value;
90
+ }
91
+ }
92
+ return selected;
93
+ };
94
+
95
+ /**
96
+ * A hole in a V3 array — an index written before an earlier one exists — is
97
+ * skipped by forEach, which would silently shift every later item and lose its
98
+ * position. Refuse instead: the position is data here, and a quiet shift is
99
+ * worse than a stop.
100
+ */
101
+ const assertDense = (array, label) => {
102
+ if (!Array.isArray(array)) return array;
103
+ for (let i = 0; i < array.length; i += 1) {
104
+ if (!(i in array)) {
105
+ throw new Error(
106
+ `v4 decompose: ${label} has a hole at index ${i} (length ${array.length}). ` +
107
+ "Array position carries meaning here, so the gap must be filled or the entry removed before decomposing."
108
+ );
109
+ }
110
+ }
111
+ return array;
55
112
  };
56
113
 
57
114
  const isEmpty = (v) =>
@@ -153,17 +210,19 @@ const decompose = (v3, { idFactory } = {}) => {
153
210
  * malformed data. Failing loudly beats losing an entry.
154
211
  */
155
212
  const mergeSource = (items, rule, sourceName) => {
156
- const seen = new Set();
213
+ const seen = new Map();
214
+ assertDense(items, sourceName);
157
215
  items.forEach((item, index) => {
158
216
  const correlationValue = item[correlateBy];
159
217
  if (correlationValue !== undefined) {
160
218
  if (seen.has(correlationValue)) {
161
219
  throw new Error(
162
- `v4 decompose: ${sourceName}[${index}] repeats ${correlateBy} "${correlationValue}". ` +
220
+ `v4 decompose: ${sourceName}[${index}] repeats ${correlateBy} "${correlationValue}", ` +
221
+ `already used by ${sourceName}[${seen.get(correlationValue)}]. ` +
163
222
  `${correlateBy} must be unique within an array — it is what correlates the two Title sources.`
164
223
  );
165
224
  }
166
- seen.add(correlationValue);
225
+ seen.set(correlationValue, index);
167
226
  }
168
227
  Object.assign(upsert(correlationValue), selectKeys(item, rule));
169
228
  });
@@ -171,6 +230,10 @@ const decompose = (v3, { idFactory } = {}) => {
171
230
 
172
231
  // titlesToBeSold order leads; ownership-only titles are appended after.
173
232
  mergeSource(titleItems, titlesRule, "propertyPack.titlesToBeSold");
233
+ // Which titles V3 actually listed under titlesToBeSold, as opposed to knowing
234
+ // only from ownership. Without this, a title whose sole titlesToBeSold key is
235
+ // titleNumber is indistinguishable from one that was never listed there.
236
+ const listedUnderTitles = new Set(titles);
174
237
  mergeSource(
175
238
  ownershipItems,
176
239
  ownershipsRule,
@@ -182,7 +245,7 @@ const decompose = (v3, { idFactory } = {}) => {
182
245
  });
183
246
 
184
247
  // --- Person + participant context ---------------------------------------
185
- const participants = v3.participants || [];
248
+ const participants = assertDense(v3.participants || [], "participants");
186
249
  const persons = [];
187
250
  const personIndexById = new Map();
188
251
  const participantContext = [];
@@ -225,7 +288,7 @@ const decompose = (v3, { idFactory } = {}) => {
225
288
  assignIfPresent(
226
289
  transaction,
227
290
  "titlesToBeSold",
228
- titles.map((t) => t.id)
291
+ titles.filter((t) => listedUnderTitles.has(t)).map((t) => t.id)
229
292
  );
230
293
  assignIfPresent(transaction, "participants", participantContext);
231
294
 
@@ -392,31 +455,41 @@ const recompose = ({
392
455
  const propertyPack = omit(Property || {}, ["id"]);
393
456
 
394
457
  // Split each Title back into the two source arrays it was merged from.
395
- // A key shared by both sources is written to every array that gets an entry;
396
- // a Title carrying *only* shared keys goes to the primarySource array alone.
458
+ // Transaction.titlesToBeSold says which titles V3 listed under titlesToBeSold,
459
+ // so an entry is emitted for every one of them — including a title whose only
460
+ // key there was titleNumber, which is every transaction between the seller
461
+ // naming the title and the deeds arriving.
397
462
  const titlesOwned = titlesRule.keys.values;
398
463
  const ownershipsOwned = ownershipsRule.keys.values;
464
+ const schemaKeys = new Set([...titlesOwned, ...ownershipsOwned, "id"]);
399
465
 
400
466
  const titlesToBeSold = [];
401
467
  const ownershipsToBeTransferred = [];
402
468
 
403
- // Transaction.titlesToBeSold is the authoritative order for Title entities.
404
469
  const titleById = new Map(titles.map((t) => [t.id, t]));
405
- const orderedTitles = (transaction.titlesToBeSold || []).map(
406
- (id, index) => titleById.get(id) || titles[index]
407
- );
408
- const titleList = orderedTitles.length ? orderedTitles.filter(Boolean) : titles;
470
+ const listedIds = transaction.titlesToBeSold || [];
471
+ const listed = listedIds.map((id) => titleById.get(id)).filter(Boolean);
472
+ // Ordered by the listed titles first, then any known only from ownership.
473
+ const titleList = [...listed, ...titles.filter((t) => !listedIds.includes(t.id))];
409
474
 
410
475
  for (const title of titleList) {
411
- const titlesPart = pick(title, titlesOwned);
412
476
  const ownershipPart = pick(title, ownershipsOwned);
413
-
414
- const hasTitlesOnly = Object.keys(omit(titlesPart, SHARED_TITLE_KEYS)).length > 0;
415
- const hasOwnershipOnly =
477
+ const hasOwnership =
416
478
  Object.keys(omit(ownershipPart, SHARED_TITLE_KEYS)).length > 0;
417
479
 
418
- if (hasTitlesOnly || !hasOwnershipOnly) titlesToBeSold.push(titlesPart);
419
- if (hasOwnershipOnly) ownershipsToBeTransferred.push(ownershipPart);
480
+ if (listed.includes(title)) {
481
+ const titlesPart = pick(title, titlesOwned);
482
+ // Extension keys go back to the rule that took them (titlesToBeSold is
483
+ // the declared sink); see mapping.extensionPolicy.
484
+ if (titlesRule.extensions === "passthrough") {
485
+ for (const [key, value] of Object.entries(title)) {
486
+ if (!schemaKeys.has(key)) titlesPart[key] = value;
487
+ }
488
+ }
489
+ titlesToBeSold.push(titlesPart);
490
+ }
491
+
492
+ if (hasOwnership) ownershipsToBeTransferred.push(ownershipPart);
420
493
  }
421
494
 
422
495
  assignIfPresent(propertyPack, "titlesToBeSold", titlesToBeSold);