07 State Assembly
Version: 0.1 (Draft) Date: 1 April 2026 Author: Ed Molyneux Status: Draft Parent: 00 — Architecture Overview
1. Purpose
Section titled “1. Purpose”State assembly is the process of compositing individual Verifiable Credentials into a coherent, complete transaction state object. It is the reverse of entity decomposition: where Sub-spec 01 defines how a monolithic transaction is decomposed into entities and credentials, this spec defines how those credentials are recomposed into usable state.
This is the central read-path operation in PDTF 2.0. Every consumer of transaction data — the diligence engine, the conveyancing UI, the MCP API, the v3 backward-compatible endpoints — depends on state assembly to turn a bag of signed credentials into a structured object they can query.
1.1 Why Three Composers?
Section titled “1.1 Why Three Composers?”PDTF has three distinct data formats that must coexist during migration:
| Format | Shape | Input | Consumers |
|---|---|---|---|
| v1/v3 claims | Flat combined.json | pathKey:value verified claims | Existing DE, existing API, all current consumers |
| v3 from graph | Flat combined.json (identical output) | Entity graph VCs | Same consumers, new input pipeline |
| v4 entity-based | ID-keyed entity maps | Entity graph VCs | New internal handlers, future API |
The three composition functions produce these three formats. During migration, they run in parallel to validate correctness before any cutover.
1.2 Key Decisions
Section titled “1.2 Key Decisions”| Decision | Reference | Status |
|---|---|---|
| Dual state assembly (v3 + v4 composers) | D10 | ✅ Confirmed |
| ID-keyed collections in v4 | D15 | ✅ Confirmed |
| Sparse objects + dependency pruning | D5 | 🟡 Needs LMS consensus |
2. The Three Composition Functions
Section titled “2. The Three Composition Functions”2.1 composeStateFromClaims (Existing — v1/v3)
Section titled “2.1 composeStateFromClaims (Existing — v1/v3)”The current production composer. Takes pathKey:value verified claims and assembles them into a flat combined.json state using REPLACE semantics.
Signature:
function composeStateFromClaims( claims: VerifiedClaim[]): CombinedStateV3Semantics:
- Each claim has a
claimPath(e.g./propertyPack/heating/heatingSystem/heatingType) and aclaimValue - Claims are applied in order (sorted by timestamp)
- Later claims REPLACE earlier claims at the same path — no merging, no pruning
- The output is the familiar v3
combined.jsonshape
No changes. This function continues to power all existing v3 endpoints. It is the baseline against which composeV3StateFromGraph is validated.
Known limitation: REPLACE semantics leave stale dependent data. If heatingType changes from “Central heating” to “None”, the centralHeatingDetails object still exists in the composed state because no claim explicitly removed it. Consumers must be aware of schema discriminators to interpret correctly. This is the core motivation for dependency pruning in v2.
2.2 composeV4StateFromGraph (New — Entity-Based)
Section titled “2.2 composeV4StateFromGraph (New — Entity-Based)”The new primary composer. Takes entity graph VCs and produces v4 entity-based state with ID-keyed maps, sparse object merging, and dependency pruning.
Signature:
function composeV4StateFromGraph( credentials: VerifiableCredential[], schemas: EntitySchemaMap, options?: CompositionOptions): ComposedStateV4Semantics:
- Credentials are grouped by
credentialSubject.id(entity identifier) - Within each entity, credentials are sorted by
validFrom(latest wins for conflicts) credentialSubjectsparse objects are deep-merged- A schema-aware dependency pruning pass removes stale dependent data
- The output is the v4 shape: ID-keyed maps of entities (see §4)
This is the target state. All new internal consumers should migrate to v4 state.
2.3 composeV3StateFromGraph (New — Backward Compatible)
Section titled “2.3 composeV3StateFromGraph (New — Backward Compatible)”The bridge composer. Takes entity graph VCs, composes v4 state internally, then transforms it back to v3 combined.json shape.
Signature:
function composeV3StateFromGraph( credentials: VerifiableCredential[], schemas: EntitySchemaMap, options?: CompositionOptions): CombinedStateV3Semantics:
- Internally calls
composeV4StateFromGraphto produce v4 state - Applies collection conversion rules (§10) to flatten ID-keyed maps back to arrays
- Reassigns fields that moved between entities (§5.4)
- Output MUST be identical to
composeStateFromClaimsfor the same underlying data
This is the validation bridge. During Phase 2 migration, both composeStateFromClaims and composeV3StateFromGraph run in parallel. Their outputs are diffed. Any discrepancy is a bug.
3. Entity Graph Input
Section titled “3. Entity Graph Input”3.1 What the Composer Receives
Section titled “3.1 What the Composer Receives”The composer’s input is a set of Verifiable Credentials associated with a transaction. Each VC targets a specific entity via credentialSubject.id:
[ { "type": ["VerifiableCredential", "PropertyCredential"], "issuer": "did:web:adapters.propdata.org.uk:epc", "validFrom": "2026-03-20T10:00:00Z", "credentialSubject": { "id": "urn:pdtf:uprn:100023456789", "energyEfficiency": { "certificate": { "currentEnergyRating": "C", "currentEnergyEfficiency": 72 } } }, "proof": { "..." : "..." } }, { "type": ["VerifiableCredential", "PropertyCredential"], "issuer": "did:key:z6Mkh...seller", "validFrom": "2026-03-21T14:30:00Z", "credentialSubject": { "id": "urn:pdtf:uprn:100023456789", "heating": { "heatingSystem": { "heatingType": "Central heating", "centralHeatingDetails": { "fuelType": "Mains gas", "boilerAge": "3-6 years old" } } } }, "proof": { "..." : "..." } }, { "type": ["VerifiableCredential", "TitleCredential"], "issuer": "did:web:adapters.propdata.org.uk:hmlr", "validFrom": "2026-03-19T08:00:00Z", "credentialSubject": { "id": "urn:pdtf:titleNumber:AB12345", "registerExtract": { "titleNumber": "AB12345", "tenure": "Freehold", "proprietorship": { "owners": ["Jane Smith"] } } }, "proof": { "..." : "..." } }]3.2 Credential Properties Relevant to Assembly
Section titled “3.2 Credential Properties Relevant to Assembly”| Property | Role in Assembly |
|---|---|
credentialSubject.id | Groups credential to entity — the entity identifier (DID or URN) |
credentialSubject.* | The sparse data to merge into entity state |
issuer | Determines trust level via OpenID Federation trust resolution (§7) |
validFrom | Temporal ordering — latest wins for conflicting paths |
type | Entity type classification (PropertyCredential, TitleCredential, etc.) |
credentialStatus | Revocation check — revoked credentials are excluded from assembly |
proof | Signature verification — invalid signatures are excluded |
3.3 Pre-Assembly Filtering
Section titled “3.3 Pre-Assembly Filtering”Before composition begins, credentials are filtered:
- Signature verification — invalid proofs are rejected
- Revocation check — revoked credentials (Bitstring Status List) are excluded
- Expiry check — credentials past
validUntilare excluded - OpenID Federation trust resolution — issuer must hold a Trust Mark whose
delegation.authorised_pathscovers the entity:path combinations they claim
Only credentials passing all four checks enter the composition pipeline.
3.4 Entity Type Resolution
Section titled “3.4 Entity Type Resolution”The composer must determine which entity type each credential targets. This is resolved from:
-
credentialSubject.idprefix — URN scheme reveals entity type:urn:pdtf:uprn:*→ Propertyurn:pdtf:titleNumber:*/urn:pdtf:unregisteredTitle:*→ Titleurn:pdtf:capacity:*→ SellerCapacityurn:pdtf:offer:*→ Offerurn:pdtf:gift:*→ Gifturn:pdtf:representation:*→ Representationurn:pdtf:role:*→ TransactionRoledid:key:*→ Persondid:web:*(not transaction DID) → Organisation
-
typearray — provides additional confirmation:PropertyCredential→ PropertyTitleCredential→ TitlePersonCredential→ PersonTransactionCredential→ Transaction- etc.
-
Transaction DID — credentials targeting
did:web:platform.example.com:transactions:*are Transaction credentials
4. V4 State Assembly (composeV4StateFromGraph)
Section titled “4. V4 State Assembly (composeV4StateFromGraph)”This is the core algorithm. It takes a filtered set of VCs and produces an ID-keyed entity state.
4.1 Algorithm Overview
Section titled “4.1 Algorithm Overview”Input: VerifiableCredential[]Output: ComposedStateV4
1. Group credentials by credentialSubject.id2. For each entity group: a. Sort credentials by validFrom (ascending — latest applied last, so latest wins) b. Resolve trust levels from Trust Mark delegation claims c. For conflicting paths: apply conflict resolution (§7) d. Deep-merge credentialSubject sparse objects (in sorted order) e. Apply dependency pruning against entity schema (§6) f. Record provenance (which VC contributed which paths)3. Assemble entity groups into ID-keyed maps by entity type4. Return composed v4 state4.2 Step-by-Step Example
Section titled “4.2 Step-by-Step Example”Consider a Property entity (urn:pdtf:uprn:100023456789) with three VCs arriving over time.
VC 1 — EPC adapter (trusted proxy), issued 2026-03-18:
{ "issuer": "did:web:adapters.propdata.org.uk:epc", "validFrom": "2026-03-18T10:00:00Z", "credentialSubject": { "id": "urn:pdtf:uprn:100023456789", "energyEfficiency": { "certificate": { "certificateNumber": "1234-5678-9012-3456-7890", "currentEnergyRating": "D", "currentEnergyEfficiency": 58, "potentialEnergyRating": "C", "potentialEnergyEfficiency": 75, "lodgementDate": "2023-06-15" } } }}VC 2 — Seller (account provider), issued 2026-03-20:
{ "issuer": "did:key:z6Mkh...seller", "validFrom": "2026-03-20T14:30:00Z", "credentialSubject": { "id": "urn:pdtf:uprn:100023456789", "heating": { "heatingSystem": { "heatingType": "Central heating", "centralHeatingDetails": { "fuelType": "Mains gas", "boilerAge": "3-6 years old" } } }, "address": { "line1": "42 Oak Lane", "town": "Peebles", "postcode": "EH45 8AB" } }}VC 3 — Updated EPC (trusted proxy), issued 2026-03-22:
{ "issuer": "did:web:adapters.propdata.org.uk:epc", "validFrom": "2026-03-22T09:00:00Z", "credentialSubject": { "id": "urn:pdtf:uprn:100023456789", "energyEfficiency": { "certificate": { "certificateNumber": "9876-5432-1098-7654-3210", "currentEnergyRating": "C", "currentEnergyEfficiency": 72, "potentialEnergyRating": "B", "potentialEnergyEfficiency": 84, "lodgementDate": "2026-03-21" } } }}Assembly process:
Step 1 — Group by entity ID:
All three target urn:pdtf:uprn:100023456789 → one entity group.
Step 2a — Sort by validFrom: VC 1 (2026-03-18) → VC 2 (2026-03-20) → VC 3 (2026-03-22)
Step 2b — Resolve trust levels:
- VC 1:
adapters.propdata.org.uk:epc→ OpenID Federation trust resolution →trusted_proxyforProperty:/energyEfficiency/* - VC 2:
did:key:z6Mkh...seller→ OpenID Federation trust resolution →account_provider(user attestation) - VC 3:
adapters.propdata.org.uk:epc→ OpenID Federation trust resolution →trusted_proxyforProperty:/energyEfficiency/*
Step 2c — Conflict resolution:
VC 1 and VC 3 both claim energyEfficiency.certificate.*. Same trust level (trustedProxy), so latest validFrom wins. VC 3 supersedes VC 1 for all energyEfficiency paths.
Step 2d — Deep merge (applied in sorted order):
After VC 1:
{ "id": "urn:pdtf:uprn:100023456789", "energyEfficiency": { "certificate": { "certificateNumber": "1234-5678-9012-3456-7890", "currentEnergyRating": "D", "currentEnergyEfficiency": 58, "potentialEnergyRating": "C", "potentialEnergyEfficiency": 75, "lodgementDate": "2023-06-15" } }}After VC 2 (merge — no conflicts, different paths):
{ "id": "urn:pdtf:uprn:100023456789", "energyEfficiency": { "certificate": { "certificateNumber": "1234-5678-9012-3456-7890", "currentEnergyRating": "D", "currentEnergyEfficiency": 58, "potentialEnergyRating": "C", "potentialEnergyEfficiency": 75, "lodgementDate": "2023-06-15" } }, "heating": { "heatingSystem": { "heatingType": "Central heating", "centralHeatingDetails": { "fuelType": "Mains gas", "boilerAge": "3-6 years old" } } }, "address": { "line1": "42 Oak Lane", "town": "Peebles", "postcode": "EH45 8AB" }}After VC 3 (merge — overwrites energyEfficiency from VC 1):
{ "id": "urn:pdtf:uprn:100023456789", "energyEfficiency": { "certificate": { "certificateNumber": "9876-5432-1098-7654-3210", "currentEnergyRating": "C", "currentEnergyEfficiency": 72, "potentialEnergyRating": "B", "potentialEnergyEfficiency": 84, "lodgementDate": "2026-03-21" } }, "heating": { "heatingSystem": { "heatingType": "Central heating", "centralHeatingDetails": { "fuelType": "Mains gas", "boilerAge": "3-6 years old" } } }, "address": { "line1": "42 Oak Lane", "town": "Peebles", "postcode": "EH45 8AB" }}Step 2e — Dependency pruning:
Schema check: heatingType = “Central heating” → centralHeatingDetails is valid. No pruning needed.
Step 2f — Provenance record:
{ "energyEfficiency.certificate": { "vcId": "vc-3-id", "issuer": "did:web:adapters.propdata.org.uk:epc", "trustLevel": "trustedProxy", "validFrom": "2026-03-22T09:00:00Z" }, "heating": { "vcId": "vc-2-id", "issuer": "did:key:z6Mkh...seller", "trustLevel": "accountProvider", "validFrom": "2026-03-20T14:30:00Z" }, "address": { "vcId": "vc-2-id", "issuer": "did:key:z6Mkh...seller", "trustLevel": "accountProvider", "validFrom": "2026-03-20T14:30:00Z" }}4.3 Deep Merge Semantics
Section titled “4.3 Deep Merge Semantics”The merge algorithm operates on sparse JSON objects:
function deepMerge(target: object, source: object): object { for (const key of Object.keys(source)) { if ( typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key]) && typeof target[key] === 'object' && target[key] !== null && !Array.isArray(target[key]) ) { // Recursive merge for nested objects target[key] = deepMerge(target[key], source[key]); } else { // Scalar, array, or null: replace entirely target[key] = source[key]; } } return target;}Rules:
- Objects merge recursively — keys from the source are applied into the target
- Scalars replace — a new value at a leaf path overwrites the old value
- Arrays replace entirely — arrays are treated as atomic values (no element-level merge)
nullreplaces — explicitly setting a value tonullclears it- Missing keys are preserved — if the source doesn’t mention a key, the target’s value survives
Why arrays replace: Arrays in the PDTF schema are value lists (rooms, recommendations, fixtures), not entity collections. Entity collections are ID-keyed maps in v4. There is no meaningful element-level merge for value arrays — if the list changes, the whole list changes.
4.4 V4 Output Shape
Section titled “4.4 V4 Output Shape”The composed v4 state follows the structure defined in Sub-spec 01 §6:
{ "transactionId": "did:web:platform.example.com:transactions:abc123", "status": "Active", "saleContext": { "numberOfSellers": 2, "outstandingMortgage": "Yes", "existingLender": "Nationwide" }, "milestones": { "..." : "..." },
"properties": { "urn:pdtf:uprn:100023456789": { "address": { "line1": "42 Oak Lane", "town": "Peebles", "postcode": "EH45 8AB" }, "energyEfficiency": { "..." : "..." }, "heating": { "..." : "..." }, "searches": { "search-env-001": { "..." : "..." }, "search-llc-002": { "..." : "..." } } } },
"titles": { "urn:pdtf:titleNumber:AB12345": { "registerExtract": { "..." : "..." }, "ownership": { "ownershipType": "Freehold" } } },
"persons": { "did:key:z6Mkh...seller1": { "name": { "first": "Jane", "last": "Smith" } }, "did:key:z6Mkh...seller2": { "name": { "first": "John", "last": "Smith" } }, "did:key:z6Mkh...buyer": { "name": { "first": "Alice", "last": "Brown" } } },
"organisations": { "did:web:smithandco.law": { "name": "Smith & Co Solicitors", "type": "lawFirm" }, "did:web:acmeestates.co.uk": { "name": "Acme Estates", "type": "estateAgency" }, "did:web:joneslegal.co.uk": { "name": "Jones Legal", "type": "lawFirm" }, "did:web:bigbank.co.uk": { "name": "Big Bank plc", "type": "lender" } },
"participants": [ { "participant": "did:key:z6Mkh...seller1", "participantId": "s1" }, { "participant": "did:key:z6Mkh...seller2", "participantId": "s2" }, { "participant": "did:key:z6Mkh...buyer", "participantId": "b1" }, { "participant": "did:key:z6Mkj...c1", "participantId": "c1", "organisation": "Smith & Co Solicitors", "organisationReference": "SC/118" }, { "participant": "did:key:z6Mkj...a1", "participantId": "a1", "organisation": "Acme Estates" }, { "participant": "did:key:z6Mkj...c2", "participantId": "c2", "organisation": "Jones Legal", "organisationReference": "JL/42" }, { "participant": "did:web:bigbank.co.uk", "participantId": "l1" } ],
"offers": { "o1": { "amount": 450000, "currency": "GBP", "status": "Accepted" } },
"sellerCapacities": { "urn:pdtf:capacity:own-1": { "seller": "did:key:z6Mkh...seller1", "transaction": "did:web:platform.example.com:transactions:tx-789", "sellersCapacity": { "capacity": "Legal Owner" } }, "urn:pdtf:capacity:own-2": { "seller": "did:key:z6Mkh...seller2", "transaction": "did:web:platform.example.com:transactions:tx-789", "sellersCapacity": { "capacity": "Legal Owner" } } },
"offerCredentials": { "urn:pdtf:offer:off-1": { "buyer": "did:key:z6Mkh...buyer", "transaction": "did:web:platform.example.com:transactions:tx-789", "offerId": "o1", "amount": 450000, "currency": "GBP", "status": "Accepted" } },
"gifts": {},
"representations": { "urn:pdtf:representation:rep-1": { "representative": "did:key:z6Mkj...c1", "representedParty": "did:key:z6Mkh...seller1", "role": "Seller's Conveyancer", "transaction": "did:web:platform.example.com:transactions:tx-789" }, "urn:pdtf:representation:rep-1b": { "representative": "did:key:z6Mkj...c1", "representedParty": "did:key:z6Mkh...seller2", "role": "Seller's Conveyancer", "transaction": "did:web:platform.example.com:transactions:tx-789" }, "urn:pdtf:representation:rep-2": { "representative": "did:key:z6Mkj...a1", "representedParty": "did:key:z6Mkh...seller1", "role": "Estate Agent", "transaction": "did:web:platform.example.com:transactions:tx-789" }, "urn:pdtf:representation:rep-3": { "representative": "did:key:z6Mkj...c2", "representedParty": "did:key:z6Mkh...buyer", "role": "Buyer's Conveyancer", "transaction": "did:web:platform.example.com:transactions:tx-789" } },
"transactionRoles": { "urn:pdtf:role:tr-1": { "participant": "did:web:bigbank.co.uk", "role": "Lender", "transaction": "did:web:platform.example.com:transactions:tx-789" } },
"enquiries": {},
"_provenance": { "urn:pdtf:uprn:100023456789": { "energyEfficiency.certificate": { "vcId": "vc-epc-003", "issuer": "did:web:adapters.propdata.org.uk:epc", "trustLevel": "trustedProxy", "validFrom": "2026-03-22T09:00:00Z" } } }}4.5 The _provenance Sidecar
Section titled “4.5 The _provenance Sidecar”The provenance map is not part of the PDTF schema — it’s metadata about the composition. It records, for each entity and path, which VC contributed that data. This enables:
- Audit trail — which adapter or user provided each piece of data
- Trust display — UI can show trust level badges per field
- Conflict debugging — when data seems wrong, provenance shows where it came from
- Selective re-verification — re-fetch only the VCs that contributed specific paths
The provenance map is keyed by entity ID, then by JSON path within that entity. Each entry records the VC ID, issuer, trust level, and issuance timestamp.
5. V3 State Assembly (composeV3StateFromGraph)
Section titled “5. V3 State Assembly (composeV3StateFromGraph)”5.1 Purpose
Section titled “5.1 Purpose”The v3 composer exists to maintain backward compatibility during migration. It produces the exact same combined.json shape that composeStateFromClaims produces, but from entity graph VCs instead of pathKey:value claims.
This is critical because:
- The diligence engine evaluates paths against v3 state
- All existing API consumers expect v3 shape
- Overlays and form mappings reference v3 paths
- The v3 composer validates the entire v4 pipeline — if v3 output matches, the entity decomposition and recomposition are correct
5.2 Algorithm
Section titled “5.2 Algorithm”Input: VerifiableCredential[]Output: CombinedStateV3
1. Compose v4 state: v4State = composeV4StateFromGraph(credentials, schemas)2. Convert v4 → v3: a. Convert ID-keyed maps to arrays (§10) b. Reassign fields across entity boundaries (§5.4) c. Rebuild participants array from persons + organisations + relationships (§5.3) d. Flatten properties map to propertyPack (single property assumed for v3) e. Flatten titles map to propertyPack.titlesToBeSold array3. Return v3 combined state5.3 Participant Reconstruction
Section titled “5.3 Participant Reconstruction”V4 decomposes v3’s participants[] array into the Transaction roster, the Person entities, and five relationship credential types. The v3 composer reconstructs participants by walking the roster in order and attaching the role and relationship fields from whichever credential names each participant.
V4 entities involved:
participants[]— the ordered roster:participantDID,participantId,organisation,organisationReference. No role.persons{}— identity data (name, contact, address, verification)sellerCapacities{}—seller→ roleSeller,sellersCapacity,dateBecameOwnerOrAuthorityofferCredentials{}—buyer→ roleBuyer/Prospective Buyer,offerIdgifts{}—donor→ roleGiftor,offerId,giftDetailsrepresentations{}—representative→role,actingFor(the represented parties’participantIds)transactionRoles{}—participant→role
Reconstruction algorithm:
function reconstructParticipants(v4State: ComposedStateV4): Participant[] { const byDid = (coll: Record<string, any>, key: string) => Object.values(coll).filter((c) => c[key] !== undefined) .reduce((m, c) => ((m[c[key]] ??= []).push(c), m), {} as Record<string, any[]>);
const capacities = byDid(v4State.sellerCapacities, "seller"); const offers = byDid(v4State.offerCredentials, "buyer"); const gifts = byDid(v4State.gifts, "donor"); const reps = byDid(v4State.representations, "representative"); const roles = byDid(v4State.transactionRoles, "participant"); const localId = (did: string) => v4State.participants.find((p) => p.participant === did)?.participantId;
// Walk the roster in order — the order is authoritative for v3. return v4State.participants.map((entry) => { const did = entry.participant; const person = v4State.persons[did] ?? {}; const out: Participant = { ...person, did, participantId: entry.participantId, organisation: entry.organisation, organisationReference: entry.organisationReference };
// Exactly one role-bearing credential per participant (01 §3.2). if (capacities[did]) { const [c] = capacities[did]; Object.assign(out, { role: "Seller", sellersCapacity: c.sellersCapacity, dateBecameOwnerOrAuthority: c.dateBecameOwnerOrAuthority }); } else if (offers[did]) { const [o] = offers[did]; Object.assign(out, { role: o.status === "Accepted" ? "Buyer" : "Prospective Buyer", offerId: o.offerId }); } else if (gifts[did]) { const [g] = gifts[did]; Object.assign(out, { role: "Giftor", offerId: g.offerId, giftDetails: g.giftDetails }); } else if (reps[did]) { // Several Representations (one per represented party) fold into one // participant with an actingFor list; they share the same role. Object.assign(out, { role: reps[did][0].role, actingFor: reps[did].map((r) => localId(r.representedParty) ?? r.representedParty) }); } else if (roles[did]) { Object.assign(out, { role: roles[did][0].role }); } // A participant named by no credential has no role — which is what // revocation of their credential should mean.
return out; });}Role mapping (v4 → v3): none required. The role value carried on Representation and TransactionRole credentials is the v3 participant role enum (Seller's Conveyancer, Estate Agent, Lender, …), and the roles implied by SellerCapacity, Offer and Gift are Seller, Buyer / Prospective Buyer and Giftor.
5.4 Field Reassignment (V4 → V3)
Section titled “5.4 Field Reassignment (V4 → V3)”Several fields moved between entities in the v4 restructuring. The v3 composer must move them back:
Transaction → Property (v4 → v3):
v4: transaction.saleContext.numberOfSellersv3: propertyPack.ownership.numberOfSellers
v4: transaction.saleContext.numberOfNonUkResidentSellersv3: propertyPack.ownership.numberOfNonUkResidentSellers
v4: transaction.saleContext.outstandingMortgagev3: propertyPack.ownership.outstandingMortgage
v4: transaction.saleContext.existingLenderv3: propertyPack.ownership.existingLender
v4: transaction.saleContext.hasHelpToBuyEquityLoanv3: propertyPack.ownership.hasHelpToBuyEquityLoan
v4: transaction.saleContext.isLimitedCompanySalev3: propertyPack.ownership.isLimitedCompanySaleTitle → Property (v4 → v3):
v4: titles[titleUrn].ownership.ownershipTypev3: propertyPack.ownership.ownershipsToBeTransferred[i].ownershipType
v4: titles[titleUrn].ownership.{leaseholdDetails}v3: propertyPack.ownership.ownershipsToBeTransferred[i].{leaseholdDetails}
v4: titles[titleUrn].isFirstRegistrationv3: propertyPack.ownership.isFirstRegistrationTransaction → Property (seller confirmations):
v4: transaction.sellerConfirmations.accuracyv3: propertyPack.confirmationOfAccuracyByOwners
v4: transaction.sellerConfirmations.saleReadyv3: propertyPack.saleReadyDeclarations
v4: transaction.completionv3: propertyPack.completionAndMoving5.5 Property Flattening
Section titled “5.5 Property Flattening”V4 wraps properties in an ID-keyed map. V3 expects a flat propertyPack:
function flattenProperties(v4State: ComposedStateV4): object { const propertyIds = Object.keys(v4State.properties);
if (propertyIds.length === 0) { return {}; }
// V3 assumes single property — take the first (or only) // Multi-property support is a v4-only concern const primaryProperty = v4State.properties[propertyIds[0]]; const uprn = propertyIds[0].replace('urn:pdtf:uprn:', '');
return { ...primaryProperty, uprn: uprn, // titlesToBeSold array injected separately (§5.6) // ownership fields injected from saleContext + titles (§5.4) };}5.6 Title Array Reconstruction
Section titled “5.6 Title Array Reconstruction”V4 titles are an ID-keyed map. V3 expects propertyPack.titlesToBeSold[]:
function reconstructTitlesArray(v4State: ComposedStateV4): object[] { return Object.entries(v4State.titles).map(([titleUrn, title]) => { const titleNumber = titleUrn.replace('urn:pdtf:titleNumber:', ''); return { titleNumber: titleNumber, titleExtents: title.titleExtents, registerExtract: title.registerExtract, additionalDocuments: title.additionalDocuments // ownership.ownershipType etc. → separate ownershipsToBeTransferred array }; });}5.7 Validation: v3 Output Comparison
Section titled “5.7 Validation: v3 Output Comparison”The critical correctness check:
async function validateComposers( claims: VerifiedClaim[], credentials: VerifiableCredential[], schemas: EntitySchemaMap): Promise<ValidationResult> { const v3FromClaims = composeStateFromClaims(claims); const v3FromGraph = composeV3StateFromGraph(credentials, schemas);
const diff = deepDiff(v3FromClaims, v3FromGraph);
if (diff.length === 0) { return { valid: true }; }
return { valid: false, discrepancies: diff.map(d => ({ path: d.path, fromClaims: d.left, fromGraph: d.right, analysis: classifyDiscrepancy(d) })) };}Expected discrepancy categories:
- Pruning improvements — v3-from-graph may correctly prune stale dependent data that v3-from-claims preserves (this is a feature, not a bug — but must be tracked)
- Ordering differences — array ordering may differ if v3-from-claims relies on claim insertion order
- Null handling — sparse object merge may handle absent-vs-null differently
During Phase 2, discrepancies are logged and triaged. The goal is zero unexpected discrepancies before Phase 3 cutover.
6. Dependency Pruning
Section titled “6. Dependency Pruning”6.1 The Problem
Section titled “6.1 The Problem”When a discriminator value changes, dependent branches become stale. With REPLACE semantics (v1/v3), stale data is never cleaned up — it persists silently in the composed state.
Example — Heating:
- Seller fills in BASPI form:
heatingType= “Central heating”, pluscentralHeatingDetails(fuel type, boiler age, etc.) - Seller later corrects:
heatingType= “None” (the property has no heating) - In v1: both claims exist. The composed state has
heatingType: "None"ANDcentralHeatingDetails: { fuelType: "Mains gas", ... }. These are contradictory but nothing cleans up the stale data.
Example — Planning:
- Seller answers:
planningPermissionRequired= “Yes” - Seller fills in:
planningPermissionDetails= { … } - Seller corrects:
planningPermissionRequired= “No” - In v1:
planningPermissionRequired: "No"coexists withplanningPermissionDetails: { ... }
6.2 The Solution: Schema-Aware Pruning
Section titled “6.2 The Solution: Schema-Aware Pruning”After deep-merging all VCs for an entity, the composer performs a pruning pass that walks the entity schema and removes branches that are no longer valid given the current discriminator values.
Definition: A discriminator is a JSON Schema construct that makes the validity of one set of fields conditional on the value of another field. In JSON Schema, these are expressed through:
oneOf/anyOfwith discriminating propertiesif/then/elseconditional schemasdependencies(property dependencies)allOfwith conditional sub-schemas
6.3 Pruning Algorithm
Section titled “6.3 Pruning Algorithm”function applyDependencyPruning( entity: object, schema: JSONSchema): object { // 1. Walk the schema to build a dependency graph const depGraph = buildDependencyGraph(schema);
// 2. For each discriminator in the graph: for (const discriminator of depGraph.discriminators) { const currentValue = getValueAtPath(entity, discriminator.path);
if (currentValue === undefined) continue;
// 3. Find which branch is active for the current value const activeBranch = discriminator.branches.find( b => b.matchesValue(currentValue) );
// 4. Prune all inactive branches for (const branch of discriminator.branches) { if (branch === activeBranch) continue;
for (const dependentPath of branch.dependentPaths) { deleteAtPath(entity, dependentPath); } } }
return entity;}6.4 Building the Dependency Graph
Section titled “6.4 Building the Dependency Graph”The dependency graph is extracted from the JSON Schema at startup (not per-request). It maps discriminator fields to their dependent branches:
interface DependencyGraph { discriminators: Discriminator[];}
interface Discriminator { /** Path to the discriminator field */ path: string; /** The branches this discriminator controls */ branches: Branch[];}
interface Branch { /** Values that activate this branch */ values: any[]; /** Paths that are only valid when this branch is active */ dependentPaths: string[]; /** Check if a value matches this branch */ matchesValue(value: any): boolean;}Example — Heating schema structure:
{ "heatingSystem": { "type": "object", "properties": { "heatingType": { "type": "string", "enum": ["Central heating", "Storage heaters", "Other", "None"] } }, "allOf": [{ "if": { "properties": { "heatingType": { "const": "Central heating" } } }, "then": { "properties": { "centralHeatingDetails": { "type": "object", "properties": { "fuelType": { "..." : "..." }, "boilerAge": { "..." : "..." } } } } } }] }}Extracted dependency:
{ "path": "heating.heatingSystem.heatingType", "branches": [ { "values": ["Central heating"], "dependentPaths": ["heating.heatingSystem.centralHeatingDetails"] }, { "values": ["Storage heaters"], "dependentPaths": ["heating.heatingSystem.storageHeaterDetails"] } ]}6.5 Detailed Pruning Example
Section titled “6.5 Detailed Pruning Example”Before pruning:
{ "heating": { "heatingSystem": { "heatingType": "None", "centralHeatingDetails": { "fuelType": "Mains gas", "boilerAge": "3-6 years old", "boilerMake": "Worcester Bosch", "lastServiced": "2025-01-15" } } }}Pruning pass:
- Discriminator:
heating.heatingSystem.heatingType - Current value:
"None" - Active branch: none of the defined branches match “None”
- Inactive branches: all — including the “Central heating” branch
- Prune:
heating.heatingSystem.centralHeatingDetails(dependent path of “Central heating” branch)
After pruning:
{ "heating": { "heatingSystem": { "heatingType": "None" } }}6.6 Another Example — SellerCapacity Type Discriminator
Section titled “6.6 Another Example — SellerCapacity Type Discriminator”Before pruning:
{ "ownership": { "ownershipType": "Freehold", "leaseholdInformation": { "yearsRemaining": 85, "groundRent": 250, "serviceCharge": 1200, "managingAgent": "Premier Estates" } }}The schema has an if/then on ownershipType:
- If “Leasehold” → require
leaseholdInformation - If “Freehold” →
leaseholdInformationis not applicable
Pruning pass:
- Discriminator:
ownership.ownershipType - Current value:
"Freehold" - Active branch: Freehold (no dependent paths)
- Prune:
ownership.leaseholdInformation(dependent on “Leasehold” branch)
After pruning:
{ "ownership": { "ownershipType": "Freehold" }}6.7 Nested Discriminators
Section titled “6.7 Nested Discriminators”Discriminators can be nested. When a parent discriminator prunes a branch, any discriminators within that branch are also implicitly resolved:
alterationsAndChanges.planningPermission.required = "Yes" └── planningPermissionDetails.type = "Full planning" └── fullPlanningDetails.{ ... }If required changes to “No”, the pruning pass removes planningPermissionDetails entirely — which also removes fullPlanningDetails and any discriminators within it. The implementation handles this naturally because pruning deletes the entire subtree.
6.8 Pruning and the Diligence Engine
Section titled “6.8 Pruning and the Diligence Engine”The diligence engine currently handles stale data by checking discriminator values in its rule definitions. With dependency pruning, the DE can rely on the composed state being clean — if a path exists, its discriminator conditions are satisfied.
Impact on DE rules:
- Rules that currently check
heatingType !== "None" && centralHeatingDetailscan simplify to just checkingcentralHeatingDetails(if it exists post-pruning, the heating type is compatible) - Rules that flag contradictory state (discriminator says X but details say Y) become unnecessary — pruning prevents contradictions
- The DE migration path (Sub-spec 08) should document which rules simplify after pruning is enabled
6.9 Consensus Requirement (D5)
Section titled “6.9 Consensus Requirement (D5)”Dependency pruning changes the semantics of state assembly. It must be agreed with LMS and other implementers before deployment:
What needs consensus:
- The principle: “discriminator changes prune dependent branches”
- The implementation: schema-walking at startup, pruning at composition time
- The validation: pruned state passes schema validation; unpruned state may not
- The compatibility: v3-from-graph output will differ from v3-from-claims where pruning applies (these differences are improvements, but must be acknowledged)
What doesn’t need consensus:
- The deep merge algorithm (standard practice)
- The conflict resolution rules (internal to the platform initially)
- The v4 state shape (new, no backward-compat constraint)
6.10 Assembler Pruning Obligation (Pending Q1.1)
Section titled “6.10 Assembler Pruning Obligation (Pending Q1.1)”If incremental MERGE semantics are adopted for any credential type, the assembler MUST apply schema dependency rules to prune stale paths. Pruning rules are derived from the schema’s if/then/else conditions and oneOf discriminators. Issuers are stateless and have no visibility of assembled state — they cannot be expected to clear dependent paths. The assembler is the only component with full context to apply pruning correctly.
This obligation exists because:
- Issuers assert what they know at the time of issuance — an issuer changing
heatingTypetoNonedoes not know that a previous credential assertedcentralHeatingDetails - Only the assembler sees all credentials for an entity and can evaluate which schema branches are active
- The schema’s existing conditional constructs (
if/then/else,oneOfdiscriminators) already define the dependency rules — the assembler applies them during composition
For adapter-issued credentials (EPC, title register, searches), section-level REPLACE may avoid the pruning question entirely — these issuers are authoritative for their whole subtree and re-issue complete data. For seller-attested credentials (TA6, TA7, fixtures), where data arrives incrementally, the assembler’s pruning obligation is unavoidable under MERGE semantics.
7. Conflict Resolution
Section titled “7. Conflict Resolution”7.1 When Conflicts Occur
Section titled “7.1 When Conflicts Occur”A conflict occurs when two or more VCs claim the same path on the same entity. For example:
- An EPC adapter VC claims
energyEfficiency.certificate.currentEnergyRating = "C" - A seller VC also claims
energyEfficiency.certificate.currentEnergyRating = "D"
The composer must decide which value wins.
7.2 Trust Level Ordering
Section titled “7.2 Trust Level Ordering”The primary resolution mechanism is trust level, defined by the Trusted Issuer Registry:
rootIssuer > trustedProxy > accountProvider| Trust Level | Description | Example Issuers |
|---|---|---|
rootIssuer | Primary data source — the authority itself | HMLR, MHCLG (EPC), Environment Agency, VOA |
trustedProxy | Adapter that fetches from the primary source and signs | PDTF HMLR adapter, PDTF EPC adapter |
accountProvider | User-attested data via a platform account | The platform (on behalf of sellers/buyers) |
Rule: A credential from a higher trust level always wins, regardless of timestamp.
7.3 Temporal Resolution (Same Trust Level)
Section titled “7.3 Temporal Resolution (Same Trust Level)”When two VCs have the same trust level, the later validFrom wins:
function resolveConflict(vc1: VC, vc2: VC, federation: FederationResolver): VC { const trust1 = tir.getTrustLevel(vc1.issuer); const trust2 = tir.getTrustLevel(vc2.issuer);
// Higher trust level wins if (trustRank(trust1) > trustRank(trust2)) return vc1; if (trustRank(trust2) > trustRank(trust1)) return vc2;
// Same trust level: latest validFrom wins if (vc1.validFrom > vc2.validFrom) return vc1; if (vc2.validFrom > vc1.validFrom) return vc2;
// Absolute tie: deterministic tiebreaker (e.g. VC ID lexicographic) return vc1.id < vc2.id ? vc1 : vc2;}
function trustRank(level: string): number { switch (level) { case 'rootIssuer': return 3; case 'trustedProxy': return 2; case 'accountProvider': return 1; default: return 0; }}7.4 Path-Level vs VC-Level Resolution
Section titled “7.4 Path-Level vs VC-Level Resolution”Conflict resolution operates at the path level, not the VC level. A single VC may contain data at multiple paths, and different paths from that VC may have different conflict outcomes:
VC from EPC adapter (trustedProxy): energyEfficiency.certificate.currentEnergyRating = "C" ← wins (higher trust) address.postcode = "EH45 8AB" ← loses (not authorised for address)
VC from seller (accountProvider): energyEfficiency.certificate.currentEnergyRating = "D" ← loses address.postcode = "EH45 8AB" ← wins (authorised for address paths)The Trust Mark’s delegation.authorised_paths determines whether an issuer is even permitted to claim a given path. Claims outside an issuer’s authorised paths are ignored during assembly (they can still be stored for audit, but don’t contribute to composed state).
7.5 Provenance Tracking
Section titled “7.5 Provenance Tracking”Every path in the composed state is annotated with its provenance — which VC contributed it and why:
{ "path": "energyEfficiency.certificate.currentEnergyRating", "value": "C", "source": { "vcId": "urn:uuid:epc-vc-2026-03-22", "issuer": "did:web:adapters.propdata.org.uk:epc", "trustLevel": "trustedProxy", "validFrom": "2026-03-22T09:00:00Z" }, "superseded": [ { "vcId": "urn:uuid:epc-vc-2026-03-18", "issuer": "did:web:adapters.propdata.org.uk:epc", "trustLevel": "trustedProxy", "validFrom": "2026-03-18T10:00:00Z", "reason": "superseded_by_later_vc" } ]}The superseded array provides a full audit trail. For most paths, it will be empty (single source). For contested paths, it records every VC that attempted to claim that path and why it lost.
7.6 Conflict Alerting
Section titled “7.6 Conflict Alerting”Some conflicts should be flagged rather than silently resolved:
- Trust level conflicts — a seller claims a different EPC rating than the EPC adapter. The adapter wins, but the discrepancy should be logged.
- Stale data — a VC with
validFrommore than N days old is superseded by a much newer VC. May indicate the old data was never updated. - Multi-issuer conflicts — two different trusted proxies claim the same path with different values. This shouldn’t happen (Trust Mark
delegation.authorised_pathsshould prevent overlap) but must be detected.
These alerts feed into the diligence engine’s quality assessment layer.
8. Migration Path
Section titled “8. Migration Path”8.1 Phase 1: composeStateFromClaims (Current)
Section titled “8.1 Phase 1: composeStateFromClaims (Current)”Status: Production. No changes.
The existing composer continues to power all v3 endpoints. Claims flow in via the current OIDC verified claims pipeline, and the composer aggregates them with REPLACE semantics.
Claims DB → composeStateFromClaims() → v3 combined state → API / DE / UI8.2 Phase 2: composeV3StateFromGraph (Parallel Validation)
Section titled “8.2 Phase 2: composeV3StateFromGraph (Parallel Validation)”Goal: Prove that the entity graph pipeline produces identical output to the claims pipeline.
Claims DB → composeStateFromClaims() → v3 state (primary) ──→ API / DE / UI ↑ compareEntity VCs → composeV3StateFromGraph() → v3 state (shadow) ──┘Steps:
- Deploy
composeV3StateFromGraphin shadow mode — it runs but its output is not served - For each transaction state request, run both composers
- Diff the outputs using
validateComposers()(§5.7) - Log discrepancies, triage into:
- Pruning improvements — expected, beneficial, track separately
- Ordering differences — normalise and recheck
- Genuine bugs — fix in the graph composer
- Target: zero unexpected discrepancies for 30 days
- Cutover:
composeV3StateFromGraphbecomes primary,composeStateFromClaimsbecomes shadow - Deprecate
composeStateFromClaimsafter confidence period
Duration estimate: 2–4 months of parallel running, depending on discrepancy volume.
8.3 Phase 3: composeV4StateFromGraph (New Consumers)
Section titled “8.3 Phase 3: composeV4StateFromGraph (New Consumers)”Goal: Migrate internal consumers to v4 entity-based state.
Entity VCs → composeV4StateFromGraph() → v4 state ──→ New internal handlers ↘ composeV3StateFromGraph() → v3 state ──→ External API / legacy DESteps:
- Identify internal consumers that can benefit from v4 state (entity-aware operations)
- Migrate handlers one by one, starting with lowest-risk
- External v3 API continues to use
composeV3StateFromGraphindefinitely - New API endpoints (v2 API, MCP) serve v4 state directly
Consumer migration order (suggested):
- Transaction summary / dashboard (low risk, read-only)
- Document generation (benefits from entity structure)
- MCP API endpoints (new, designed for v4)
- Diligence engine (highest impact, most complex — see Sub-spec 08)
8.4 Validation Strategy
Section titled “8.4 Validation Strategy”┌─────────────────────┐ ┌──────────────────────┐│ composeStateFromClaims │ │ composeV3StateFromGraph ││ (pathKey:value input) │ │ (entity VC input) │└──────────┬────────────┘ └──────────┬─────────────┘ │ v3 output │ v3 output ▼ ▼ ┌─────────────────────────────────────┐ │ Deep Diff Comparator │ │ │ │ • Structural identity check │ │ • Path-level value comparison │ │ • Array ordering normalisation │ │ • Expected-discrepancy whitelist │ │ • Pruning-improvement tracking │ └──────────┬──────────────────────────┘ │ ▼ ┌──────────────────────┐ │ Discrepancy Log │ │ │ │ zero unexpected │ │ = ready for cutover │ └──────────────────────┘9. Existing Code
Section titled “9. Existing Code”9.1 decomposeSchema.js (Branch 263)
Section titled “9.1 decomposeSchema.js (Branch 263)”The schema extraction utility (576 lines) on branch 263-extract-separate-entity-schemas-from-combinedjson-in-preparation-for-pdtf-20 handles the forward direction: extracting entity schemas from the combined schema.
Key functions:
- Schema walking and path extraction
- Entity boundary detection
- Reference resolution (
$refhandling) - Overlay-aware extraction (respects form-specific overlays)
This is the schema decomposition. State assembly is the data composition — the reverse direction operating on credential payloads rather than schemas.
9.2 composeStateFromGraph.js (To Be Built)
Section titled “9.2 composeStateFromGraph.js (To Be Built)”The implementation of the algorithms in this spec. Located in the @pdtf/schemas package alongside decomposeSchema.js.
Planned modules:
| Module | Description | Estimated Size |
|---|---|---|
composeV4StateFromGraph.js | Core v4 composer (§4) | ~300 lines |
composeV3StateFromGraph.js | V3 bridge composer (§5) | ~400 lines |
dependencyPruning.js | Schema-aware pruning (§6) | ~250 lines |
conflictResolution.js | Trust-level and temporal resolution (§7) | ~150 lines |
collectionConversion.js | Array↔map conversion rules (§10) | ~200 lines |
provenanceTracker.js | Path-level provenance recording (§4.5) | ~100 lines |
Total estimate: ~1,400 lines of implementation code, plus tests.
9.3 Relationship to decomposeSchema.js
Section titled “9.3 Relationship to decomposeSchema.js”The two utilities are complementary:
decomposeSchema.js (existing) Input: v4/combined.json (schema) Output: Entity JSON Schemas (Property.json, Title.json, etc.) Purpose: Define the shape of credentialSubject for each entity type
composeStateFromGraph.js (new) Input: Verifiable Credentials (data instances conforming to entity schemas) Output: Composed state (v4 or v3 format) Purpose: Assemble data from multiple VCs into a single queryable stateThe entity schemas generated by decomposeSchema.js are used by composeStateFromGraph.js for:
- Entity type resolution (which schema does this VC conform to?)
- Dependency graph extraction (which fields are discriminators?)
- Validation (does the composed state pass schema validation?)
10. Collection Conversion Rules
Section titled “10. Collection Conversion Rules”10.1 Overview
Section titled “10.1 Overview”V4 uses ID-keyed maps for entity collections. V3 uses arrays. The v3 composer must convert between them. The v4 composer receives VCs targeting individual entities by ID, so maps are the natural output.
10.2 Collections Already ID-Keyed (No Conversion Needed)
Section titled “10.2 Collections Already ID-Keyed (No Conversion Needed)”These collections are already ID-keyed in v3 and remain so in v4:
| Collection | V3 Key Type | V4 Key Type | Notes |
|---|---|---|---|
offers | String key | urn:pdtf:offer:{key} | Wrap existing key in URN |
enquiries | String key | Preserved | No change |
externalIds | Pattern map | Pattern map | No change |
10.3 Collections Requiring Conversion
Section titled “10.3 Collections Requiring Conversion”These collections change from arrays (v3) to ID-keyed maps (v4):
10.3.1 Participants → Roster + Entities + Credentials
Section titled “10.3.1 Participants → Roster + Entities + Credentials”The most complex conversion. V3’s participants[] becomes the Transaction roster, Person entities, and the relationship credentials that carry role:
V3: participants[] (mixed array of all participant types, each with a role) ↕V4: participants[] — ordered roster on the Transaction: DID, local id, firm. No roles. persons{} — keyed by did:key organisations{} — keyed by did:web (outside the round trip) sellerCapacities{} — keyed by urn:pdtf:capacity:* ⇒ Seller offerCredentials{} — keyed by urn:pdtf:offer:* ⇒ Buyer gifts{} — keyed by urn:pdtf:gift:* ⇒ Giftor representations{} — keyed by urn:pdtf:representation:* (role: which kind) transactionRoles{} — keyed by urn:pdtf:role:* (role: which role)V3 → V4 (decomposition):
function decomposeParticipants(tx: V3Transaction, ids: IdFactory) { const participants = tx.participants; const result = { roster: [] as RosterEntry[], persons: {}, sellerCapacities: {}, offerCredentials: {}, gifts: {}, representations: {}, transactionRoles: {} }; const transaction = ids.transaction(tx); const didOf = (p: V3Participant, i: number) => p.did ?? ids.person(p, i); const byLocalId = (id: string) => participants.find((q) => q.participantId === id || q.did === id);
participants.forEach((p, i) => { const did = didOf(p, i); if (result.persons[did]) throw new Error(`duplicate participant ${did}`); // one party, one entry
// 1. Roster entry — only what stays true regardless of any relationship result.roster.push({ participant: did, participantId: p.participantId, organisation: p.organisation, organisationReference: p.organisationReference });
// 2. Person — the party fields result.persons[did] = extractPersonFields(p);
// 3. Exactly one role-bearing credential, the most specific that applies if (p.role === "Seller" || p.sellersCapacity) { result.sellerCapacities[ids.sellerCapacity(p)] = { seller: did, transaction, sellersCapacity: p.sellersCapacity, dateBecameOwnerOrAuthority: p.dateBecameOwnerOrAuthority }; } else if (p.giftDetails || p.role === "Giftor") { result.gifts[ids.gift(p)] = { donor: did, transaction, offerId: p.offerId, giftDetails: p.giftDetails }; } else if (p.offerId) { result.offerCredentials[ids.offer(p)] = { buyer: did, transaction, offerId: p.offerId, ...tx.offers?.[p.offerId] }; } else if (p.actingFor?.length) { // One Representation per represented party for (const target of p.actingFor) { const rep = byLocalId(target); result.representations[ids.representation(p, rep)] = { representative: did, representedParty: didOf(rep, participants.indexOf(rep)), role: p.role, transaction }; } } else if (p.role) { result.transactionRoles[ids.transactionRole(p)] = { participant: did, role: p.role, transaction }; } // No role, no actingFor → roster entry and Person only. });
return result;}The roster order is the v3 participants[] order and is authoritative for recomposition. organisation and organisationReference stay on the roster rather than on any credential: where someone works does not stop being true when a representation ends.
V4 → V3 (reconstruction): See §5.3.
10.3.2 Titles
Section titled “10.3.2 Titles”V3: propertyPack.titlesToBeSold[] (array, indexed) ↕V4: titles{} (keyed by urn:pdtf:titleNumber:{titleNumber})ID source: titleNumber field within each title object. Natural key.
Conversion:
// V3 → V4const titles = {};for (const title of v3State.propertyPack.titlesToBeSold) { const urn = `urn:pdtf:titleNumber:${title.titleNumber}`; titles[urn] = { ...title }; delete titles[urn].titleNumber; // Now part of the key}
// V4 → V3const titlesToBeSold = Object.entries(v4State.titles).map( ([urn, title]) => ({ titleNumber: urn.replace('urn:pdtf:titleNumber:', ''), ...title }));10.3.3 Searches
Section titled “10.3.3 Searches”V3: propertyPack.searches[] (array) ↕V4: properties[uprn].searches{} (keyed by providerReference or generated ID)ID source: providerReference if unique, otherwise {providerName}:{providerReference} composite, otherwise generated UUID.
10.3.4 Documents
Section titled “10.3.4 Documents”V3: propertyPack.documents[] (array) ↕V4: properties[uprn].documents{} (keyed by generated ID)ID source: Generated. Documents don’t have a natural unique key. Use stable UUID generated from document content hash or upload timestamp.
10.3.5 Surveys
Section titled “10.3.5 Surveys”V3: propertyPack.surveys[] (array) ↕V4: properties[uprn].surveys{} (keyed by generated ID)ID source: Generated, or surveyReference if available.
10.3.6 Valuations
Section titled “10.3.6 Valuations”V3: propertyPack.valuations[] (array) ↕V4: properties[uprn].valuations{} (keyed by valuationId)ID source: valuationId — natural key already present in the schema.
10.3.7 Contracts
Section titled “10.3.7 Contracts”V3: contracts[] (array) ↕V4: contracts{} (keyed by generated ID)10.3.8 Chain
Section titled “10.3.8 Chain”V3: chain.onwardPurchase[] (array) ↕V4: chain.onwardPurchase{} (keyed by transactionId)ID source: transactionId — natural key.
10.3.9 Media
Section titled “10.3.9 Media”V3: propertyPack.media[] (array) ↕V4: properties[uprn].media{} (keyed by generated ID)ID source: Generated from URL hash or upload order.
10.4 Value Arrays (Remain as Arrays)
Section titled “10.4 Value Arrays (Remain as Arrays)”These are value lists within entities, not entity collections. They stay as arrays in both v3 and v4:
| Array | Location | Why it stays an array |
|---|---|---|
rooms[] | Property features | Value list, no individual identity |
fixtures[] | Fixtures and fittings | Value list |
recommendations[] | EPC recommendations | Value list |
localLandCharges[] | LLC results | Value list |
conditions[] | Offer conditions | Value list |
inclusions[] | Offer inclusions | Value list |
exclusions[] | Offer exclusions | Value list |
additionalDocuments[] | Title supporting docs | Value list |
| Planning decision arrays | Alterations/changes | Value list |
| Environmental risk arrays | Environmental issues | Value list |
Rule of thumb: If items in the array don’t have individual identity (no natural key, no DID, no URN), it stays an array. If items are independently addressable entities, it becomes a map.
10.5 Round-Trip Guarantee
Section titled “10.5 Round-Trip Guarantee”For any transaction state, the following must hold:
v3State → decomposeToV4(v3State) → composeToV3(v4State) === v3StateThis means the collection conversion must be lossless. Key concerns:
- Array ordering — v3 arrays have implicit ordering. V4 maps lose ordering. When converting back, a canonical ordering must be applied (e.g. by creation timestamp, or by key sort).
- ID generation — when converting v3 → v4, generated IDs must be deterministic (content-addressable or seeded from stable data) to ensure the same input always produces the same v4 keys.
- Null vs absent — a v3 field that is
nullmust not be lost in the v4 conversion and must benull(not absent) when converting back.
11. Performance Considerations
Section titled “11. Performance Considerations”11.1 Composition Cost
Section titled “11.1 Composition Cost”State assembly is the hot path for every read operation. Its performance characteristics:
| Operation | Complexity | Notes |
|---|---|---|
| Credential grouping | O(n) | Single pass over VC list |
| Sorting by validFrom | O(n log n) per entity | Typically few VCs per entity |
| Deep merge | O(p) per entity | p = total paths across VCs |
| Dependency pruning | O(s) per entity | s = schema discriminator count (fixed, small) |
| Conflict resolution | O(c) per entity | c = conflicting paths (typically very few) |
| Collection conversion (v3) | O(e) | e = total entities across all types |
For a typical transaction with ~50 VCs and ~4,000 paths total: estimated <50ms for full composition.
11.2 Caching Strategy
Section titled “11.2 Caching Strategy”Composed state should be cached because:
- Most reads are of the same state (VCs don’t change between reads)
- Composition involves signature verification, OpenID Federation trust resolution, and deep merging
- Multiple consumers may request state for the same transaction concurrently
Cache design:
interface StateCache { /** Cache key: transaction DID + hash of contributing VC IDs */ key: string; /** Composed v4 state */ v4State: ComposedStateV4; /** Composed v3 state (derived from v4) */ v3State: CombinedStateV3; /** Provenance map */ provenance: ProvenanceMap; /** Timestamp of last VC that contributed */ lastVcTimestamp: string; /** Set of VC IDs that contributed (for invalidation) */ contributingVcIds: Set<string>;}11.3 Cache Invalidation
Section titled “11.3 Cache Invalidation”The cache is invalidated when:
- New VC arrives — any new credential for any entity in the transaction
- VC revoked — a credential in
contributingVcIdsis revoked - Trust Mark updated — trust levels may change, affecting conflict resolution
- TTL expiry — background refresh after configurable TTL (e.g. 5 minutes)
Invalidation strategy: Event-driven. When a new VC is stored, emit an event that invalidates the cache for the affected transaction. Don’t recompose eagerly — wait for the next read.
11.4 Incremental Recomposition
Section titled “11.4 Incremental Recomposition”For performance optimisation (future), the composer could support incremental updates:
- When a single new VC arrives, only recompose the affected entity
- Deep merge the new VC’s
credentialSubjectinto the cached entity state - Re-run dependency pruning for that entity only
- Re-run conflict resolution for affected paths only
- Update provenance for affected paths
Complexity: O(p_new) where p_new is the number of paths in the new VC, vs O(p_total) for full recomposition.
Risk: Incremental recomposition may produce different results than full recomposition if there are ordering-dependent interactions between VCs. Must be validated: incremental(state, newVC) === fullRecompose(allVCs).
Recommendation: Implement full recomposition first. Add incremental as an optimisation only if composition latency becomes a bottleneck in production.
12. Open Questions
Section titled “12. Open Questions”12.1 For LMS / Implementer Discussion
Section titled “12.1 For LMS / Implementer Discussion”-
Pruning semantics agreement — Does LMS agree that discriminator changes should prune dependent branches? This changes composed state output. The alternative is to continue with REPLACE semantics and handle stale data in consumers. (Relates to D5.)
-
Array ordering guarantee — Should the v3-from-graph composer guarantee the same array ordering as the v3-from-claims composer? If so, what ordering convention? Insertion order is fragile.
-
Multi-property transactions — When a transaction has multiple properties (e.g. house + garage), how does the v3 composer flatten
properties{}topropertyPack? Options:- Primary property only (lose data)
- Merge all properties (conflicts)
- Array of property packs (v3 schema change)
-
Conflict visibility — Should trust-level conflicts be visible to transaction participants? E.g., should a seller see “The EPC adapter says your energy rating is C, but you claimed D”? Or is this internal only?
12.2 Internal (Platform)
Section titled “12.2 Internal (Platform)”-
Dependency graph extraction from real schemas — The pruning algorithm requires walking the actual PDTF JSON Schemas to identify discriminators. How complete are the current schemas’ use of
if/then/elseandoneOf? Are there discriminator patterns that aren’t expressed in the schema today? -
Provenance storage — Where does the
_provenancesidecar live? Options:- In the composed state object (convenient, but bloats responses)
- Separate endpoint/query (cleaner, but extra fetch)
- Only in the cache (lost on cache eviction)
-
Testing strategy — How to generate realistic VC sets for testing? Options:
- Convert existing claims to VCs (automated migration script)
- Manual VC creation for test cases
- Snapshot real transaction claims and convert
-
Incremental recomposition correctness — How to prove that incremental recomposition always matches full recomposition? Formal proof or extensive fuzzing?
13. Implementation Notes
Section titled “13. Implementation Notes”13.1 Implementation Order
Section titled “13.1 Implementation Order”-
dependencyPruning.js— Start here. It’s the most novel component and needs the most validation. Build the schema walker, extract discriminators from real PDTF schemas, write extensive tests. -
conflictResolution.js— Trust level lookup and temporal ordering. Depends on the Trust Markdelegationclaim format being stable (Sub-spec 04). -
composeV4StateFromGraph.js— Core composer. Once pruning and conflict resolution are solid, this is straightforward deep merging with orchestration. -
collectionConversion.js— Array↔map conversion. Well-defined transformation rules, mostly mechanical. -
composeV3StateFromGraph.js— Bridge composer. Depends on all above components plus field reassignment rules. -
provenanceTracker.js— Can be added incrementally. Doesn’t affect correctness, only observability.
13.2 Test Strategy
Section titled “13.2 Test Strategy”Unit tests:
- Deep merge: various sparse object combinations, null handling, array replacement
- Dependency pruning: every discriminator pattern in the PDTF schema
- Conflict resolution: trust level ordering, temporal ordering, tie-breaking
- Collection conversion: round-trip for every collection type
Integration tests:
- Full composition from realistic VC sets
- V3 comparison:
composeV3StateFromGraphoutput matchescomposeStateFromClaimsfor known transactions - Round-trip: v3 → v4 → v3 identity check
Property-based tests (recommended):
- Generate random sparse objects → merge → verify all paths present
- Generate random discriminator values → prune → verify schema validity
- Generate random VC sets → compose v4 → compose v3 → compare with claims-based v3
13.3 Error Handling
Section titled “13.3 Error Handling”| Error | Handling |
|---|---|
| VC with unknown entity type | Log warning, skip VC, continue composition |
VC with invalid credentialSubject.id | Log error, skip VC |
| Schema not found for entity type | Log error, skip entity (cannot prune without schema) |
| Federation trust resolution failure | Fall back to account_provider trust level |
| Merge produces invalid state (fails schema validation) | Log error, return last valid state, alert |
| Collection conversion loses data | Fatal error — this should never happen in production |
13.4 Logging and Observability
Section titled “13.4 Logging and Observability”State assembly is a critical path. Every composition should log:
- Transaction ID
- Number of input VCs (total and per entity type)
- Number of VCs filtered (invalid, revoked, expired, unauthorised)
- Number of conflicts resolved (and resolution reasons)
- Number of paths pruned (dependency pruning)
- Composition duration (ms)
- Cache hit/miss
- Discrepancies (Phase 2 validation)
13.5 Key Decision Dependencies
Section titled “13.5 Key Decision Dependencies”| Decision | This Spec Depends On | Status |
|---|---|---|
| D5 — Sparse objects + pruning | §6 (pruning algorithm), §8 (migration) | 🟡 Needs LMS consensus |
| D10 — Dual state assembly | §2 (three composers), §8 (migration phases) | ✅ Confirmed |
| D15 — ID-keyed collections | §10 (conversion rules), §4.4 (v4 output shape) | ✅ Confirmed |
| D20 — Trust Mark entity:path combos | §7 (conflict resolution), §3.3 (pre-assembly filtering) | ✅ Confirmed |
| D27 — Logbook test | §5.4 (field reassignment) | ✅ Confirmed |
Appendix A: Deep Merge Walkthrough
Section titled “Appendix A: Deep Merge Walkthrough”A.1 Simple Merge (No Conflicts)
Section titled “A.1 Simple Merge (No Conflicts)”Base state:
{ "address": { "line1": "42 Oak Lane", "postcode": "EH45 8AB" }}Incoming sparse object:
{ "address": { "town": "Peebles", "county": "Scottish Borders" }, "heating": { "heatingSystem": { "heatingType": "Central heating" } }}Result:
{ "address": { "line1": "42 Oak Lane", "postcode": "EH45 8AB", "town": "Peebles", "county": "Scottish Borders" }, "heating": { "heatingSystem": { "heatingType": "Central heating" } }}A.2 Overwrite at Leaf
Section titled “A.2 Overwrite at Leaf”Base state:
{ "energyEfficiency": { "certificate": { "currentEnergyRating": "D", "currentEnergyEfficiency": 58 } }}Incoming (new EPC):
{ "energyEfficiency": { "certificate": { "currentEnergyRating": "C", "currentEnergyEfficiency": 72 } }}Result:
{ "energyEfficiency": { "certificate": { "currentEnergyRating": "C", "currentEnergyEfficiency": 72 } }}Both leaf values overwritten. Structure preserved.
A.3 Array Replacement
Section titled “A.3 Array Replacement”Base state:
{ "energyEfficiency": { "recommendations": [ { "measure": "Loft insulation", "rating": "A" }, { "measure": "Double glazing", "rating": "B" } ] }}Incoming (updated recommendations):
{ "energyEfficiency": { "recommendations": [ { "measure": "Solar panels", "rating": "A" }, { "measure": "Heat pump", "rating": "A" }, { "measure": "Loft insulation", "rating": "A" } ] }}Result:
{ "energyEfficiency": { "recommendations": [ { "measure": "Solar panels", "rating": "A" }, { "measure": "Heat pump", "rating": "A" }, { "measure": "Loft insulation", "rating": "A" } ] }}Entire array replaced. No element-level merge.
A.4 Explicit Null
Section titled “A.4 Explicit Null”Base state:
{ "heating": { "heatingSystem": { "heatingType": "Central heating", "centralHeatingDetails": { "fuelType": "Mains gas" } } }}Incoming (explicitly null out details):
{ "heating": { "heatingSystem": { "centralHeatingDetails": null } }}Result:
{ "heating": { "heatingSystem": { "heatingType": "Central heating", "centralHeatingDetails": null } }}The null is an explicit signal: “this field has been cleared.” Dependency pruning may also remove it if the schema discriminator makes it invalid, but the explicit null takes effect first.
Appendix B: Full Composition Example
Section titled “Appendix B: Full Composition Example”A complete worked example showing composition of a realistic transaction from VCs through to v4 and v3 state.
B.1 Input: Five VCs for a Transaction
Section titled “B.1 Input: Five VCs for a Transaction”VC 1 — Transaction metadata (the platform):
{ "type": ["VerifiableCredential", "TransactionCredential"], "issuer": "did:web:platform.example.com", "validFrom": "2026-03-15T10:00:00Z", "credentialSubject": { "id": "did:web:platform.example.com:transactions:tx-42", "status": "Active", "saleContext": { "numberOfSellers": 1, "outstandingMortgage": "Yes", "existingLender": "Nationwide" } }}VC 2 — Property data (seller attestation):
{ "type": ["VerifiableCredential", "PropertyCredential"], "issuer": "did:key:z6Mkh...seller", "validFrom": "2026-03-16T14:00:00Z", "credentialSubject": { "id": "urn:pdtf:uprn:100023456789", "address": { "line1": "42 Oak Lane", "town": "Peebles", "postcode": "EH45 8AB" }, "heating": { "heatingSystem": { "heatingType": "Central heating", "centralHeatingDetails": { "fuelType": "Mains gas", "boilerAge": "3-6 years old" } } }, "buildInformation": { "propertyType": "Detached", "approximateAge": "1900-1929" } }}VC 3 — EPC data (trusted proxy):
{ "type": ["VerifiableCredential", "PropertyCredential"], "issuer": "did:web:adapters.propdata.org.uk:epc", "validFrom": "2026-03-17T09:00:00Z", "credentialSubject": { "id": "urn:pdtf:uprn:100023456789", "energyEfficiency": { "certificate": { "certificateNumber": "1234-5678-9012-3456-7890", "currentEnergyRating": "C", "currentEnergyEfficiency": 72, "lodgementDate": "2026-03-15" } } }}VC 4 — Title data (HMLR proxy):
{ "type": ["VerifiableCredential", "TitleCredential"], "issuer": "did:web:adapters.propdata.org.uk:hmlr", "validFrom": "2026-03-17T10:00:00Z", "credentialSubject": { "id": "urn:pdtf:titleNumber:AB12345", "registerExtract": { "titleNumber": "AB12345", "tenure": "Freehold", "proprietorship": { "owners": [{ "name": "Jane Smith", "address": "42 Oak Lane, Peebles" }] }, "priceHistory": [ { "date": "2018-05-14", "price": 320000 } ] }, "titleExtents": { "type": "Feature", "geometry": { "type": "Polygon", "coordinates": ["..."] } }, "ownership": { "ownershipType": "Freehold" } }}VC 5 — Person identity (the platform):
{ "type": ["VerifiableCredential", "PersonCredential"], "issuer": "did:web:platform.example.com", "validFrom": "2026-03-15T09:00:00Z", "credentialSubject": { "id": "did:key:z6Mkh...seller", "name": { "first": "Jane", "last": "Smith" }, "contact": { "email": "jane@example.com", "phone": "07700900000" } }}B.2 Composition Steps
Section titled “B.2 Composition Steps”Step 1 — Group by entity:
| Entity ID | VCs |
|---|---|
did:web:platform.example.com:transactions:tx-42 | VC 1 |
urn:pdtf:uprn:100023456789 | VC 2, VC 3 |
urn:pdtf:titleNumber:AB12345 | VC 4 |
did:key:z6Mkh...seller | VC 5 |
Step 2 — Compose each entity group:
Property (VC 2 + VC 3, sorted by validFrom):
- Apply VC 2 (seller attestation): address, heating, buildInformation
- Apply VC 3 (EPC proxy): energyEfficiency
- No conflicts (different paths)
- Dependency pruning: heatingType = “Central heating” → centralHeatingDetails valid ✓
Transaction (VC 1 only): direct use of credentialSubject.
Title (VC 4 only): direct use of credentialSubject.
Person (VC 5 only): direct use of credentialSubject.
Step 3 — Assemble v4 state: See §4.4 for the output shape (this example produces a subset of that structure).
B.3 V3 Conversion
Section titled “B.3 V3 Conversion”From the v4 state, the v3 composer:
- Flattens
properties["urn:pdtf:uprn:100023456789"]→propertyPack - Converts
titles["urn:pdtf:titleNumber:AB12345"]→propertyPack.titlesToBeSold[0] - Moves
transaction.saleContext.numberOfSellers→propertyPack.ownership.numberOfSellers - Moves
titles[...].ownership.ownershipType→propertyPack.ownership.ownershipsToBeTransferred[0].ownershipType - Reconstructs
participants[]by walking the roster and attachingrole: "Seller"andsellersCapacityfrom each SellerCapacity credential (no representations or offers in this example) - Adds
propertyPack.uprn = "100023456789"
Result: A v3 combined.json that matches what composeStateFromClaims would produce from the equivalent pathKey:value claims.
Appendix C: Dependency Graph for Common PDTF Discriminators
Section titled “Appendix C: Dependency Graph for Common PDTF Discriminators”A non-exhaustive list of discriminator patterns in the PDTF schema that the pruning pass must handle:
| Discriminator Path | Values | Dependent Branches |
|---|---|---|
heating.heatingSystem.heatingType | ”Central heating” | centralHeatingDetails |
| ”Storage heaters” | storageHeaterDetails | |
| ”Other” | otherHeatingDetails | |
| ”None” | (no dependents) | |
ownership.ownershipType | ”Leasehold” | leaseholdInformation |
| ”Commonhold” | commonholdInformation | |
| ”Freehold” | (no dependents) | |
waterAndDrainage.waterSupplyType | ”Mains” | mainsWaterDetails |
| ”Private” | privateWaterDetails | |
electricity.electricitySupplyType | ”Mains” | mainsElectricityDetails |
| ”Off-grid” | offGridDetails | |
alterationsAndChanges.planningPermission.required | ”Yes” | planningPermissionDetails |
| ”No” | (no dependents) | |
alterationsAndChanges.buildingRegulations.required | ”Yes” | buildingRegulationsDetails |
| ”No” | (no dependents) | |
insurance.buildingInsurance.hasInsurance | ”Yes” | insuranceDetails |
| ”No” | noInsuranceReason | |
parking.parkingArrangements | ”Garage” | garageDetails |
| ”Driveway” | drivewayDetails | |
| ”Allocated space” | allocatedSpaceDetails |
Note: This table is illustrative. The authoritative list comes from walking the actual JSON Schemas. The implementation must discover discriminators dynamically, not from a hardcoded list.