Salta ai contenuti

0045. Preserve guest identities when resolving duplicates

Questi contenuti non sono ancora disponibili nella tua lingua.

  • Status: accepted
  • Date: 2026-10-31
  • Deciders: GuestCraft maintainers

The domain analysis treats contact details as shared information, not unique identifiers. Families and assistants may share an e-mail address or phone. An operator must decide whether two records represent the same person. The Guests workflow previously supported search, editing and booking history but no merge, consent history or ownership correction. The workspace and property boundaries of ADR 0036 also apply to these operations.

Propose duplicate pairs using a matching normalized e-mail or phone. A folded name may explain an already strong match, but a name alone does not propose a pair. The current model has no birth date or document number; do not add legal identity fields for matching. Store normalized e-mail, phone digits and folded name on every database write, backfill existing guests, and index each key by workspace for live identities. The normalization definition generates the SQL implementation and is checked against the memory adapter. Union indexed equality matches, aggregate their reasons, and order strongest pairs first. A key shared by more than five live guests produces one shared-contact group with paged membership, rather than every possible pair. These are review signals, never automatic identity matches. Filter dismissed pairs before pagination. Dismissal and undo are audited; repeating the current state does not create another audit entry or event.

A merge is a deliberate transition at POST /guests/{id}/merge. The path names the survivor. Require its strong If-Match and an explicit absorbed guest version in the body, and offer a choice of source for each editable contact field. Omitted API choices retain the survivor’s field; the comparison screen shows those defaults explicitly. Guest and reservation reference writes take a shared transaction advisory gate per workspace before guest or reservation row locks; only a merge takes that gate exclusively. Ordinary writers at different properties proceed concurrently. The write-property statement first locks its property with FOR NO KEY UPDATE, then takes the shared gate. The following reservation read uses a fresh READ COMMITTED snapshot before checking its version. A waiting edit therefore sees a merge’s version increment and refuses a stale precondition. The gate stays held until commit, so a later merge cannot intervene between that read and save. This keeps each reservation change’s version distinct without adding a client statement. The property lock serializes writers while permitting foreign-key KEY SHARE checks by a merge; property identifiers are immutable, and deletion or key changes still conflict. Merge excludes those writers, locks both identities in identifier order, and checks both versions again before writing. No path upgrades a shared lock to an exclusive lock. Keep the absorbed identity and its original contact data with a survivor pointer. Preserve merge provenance and history; do not mutate immutable document snapshots or historical audit records. A survivor is marked internally as manually merged and becomes operator-owned, including when its reservations came from an OTA. Ordinary audited edits can correct its contacts. Later OTA revisions update booking facts but preserve its chosen contacts and guest version. Store the latest incoming contact snapshot separately on the reservation. When it differs from the live profile, show its channel, revision time and differing fields. Applying those fields is an ordinary version-checked guest edit, not automatic identity replacement. The marker is persisted on the guest by the server and backfilled from provenance. Ordinary edits preserve it; clients cannot set it. It remains authoritative even when a staff member can read the survivor but cannot inspect an absorbed profile’s provenance.

An explicit guest alias used for a reservation resolves through the caller’s visible identities. A missing or property-hidden intermediate identity refuses the operation as guest.not_found; following an old alias never grants access to a hidden profile. The SQL trigger separately fails closed when it cannot read a referenced identity. Reservation write responses report the identity actually stored, including when a merge races the write. When creating a standalone guest or changing ownership, lock the owning/destination property before the guest gate and row, matching the order used by booking writers.

Move live reservation references to the survivor in the same transaction. Each moved reservation receives a version bump and its ordinary update audit/event, so existing representations become stale and event consumers refetch. Folios, OTA links and message threads in the current model belong to reservations, so those links keep their identities while their reservation now resolves the survivor. The current model has a booker guest reference; occupant counts are not independent guest identities. This change does not introduce reservation occupancy registration. Every guest foreign key has an explicit merge policy in packages/db/src/repositories/guest-references.ts: live references re-point, while consent and provenance identities are preserved. A real PostgreSQL catalog test enumerates all foreign keys targeting guests and fails when a reference has no policy. Future guest foreign keys must extend this list and its transactional behavior. Split folios (ADR 0029) added the first: a folio’s guest payer (folios.payer_guest_id) re-points to the survivor with a folio version bump, and a merged identity cannot be named as a payer again.

Require guests.merge for merging and guests.reassign for changing owning property; both belong to administrators by default. Both refuse property-restricted actors: a merge can affect linked reservations at properties that the actor cannot see, and ownership reassignment can change who may read a guest. Reads and ordinary guest management retain their existing property filtering.

Consent entries use a country-neutral catalog: marketing_email, marketing_phone, and marketing_sms. Each entry records granted or withdrawn, server time, actor, source and an optional note. Preserve the complete append-only history. A merge never moves or combines consent entries; a grant on the absorbed identity is not evidence of a grant on the survivor. The operator may inspect the absorbed identity’s history through the survivor’s paged merge provenance links. Restricted staff see links only when both identities are visible. Runtime grants expose identifiers and merge time, never the stored before-contact snapshots. Before confirmation, show both identities’ latest decision for each purpose and warn about withdrawals on the absorbed side. Display current decisions separately from newest-first paginated history, with actor display names and the user’s time zone. Consent recording requires guests.consents, granted to the existing guest-management roles by default, and the guest version. These records capture evidence; they do not establish a country’s legal basis or automatically send marketing.

Absorbed profiles are read-only: every identity write refuses with 409 guest.absorbed and the final visible survivor identifier. Profile reads compress the visible pointer chain; a hidden hop still fails closed. Hide write actions and booking calls to action on the tombstone page, name and link the survivor, and retain read-only consent history. Guest lists and search exclude tombstones unless include_merged=true is requested.

Migrations 0035, 0036 and 0037 are contract migrations. An older runtime must stop at the schema boundary: it does not understand absorbed identities or the new reference-resolution rules. Deploy the migration and this runtime together, then roll forward rather than running the previous guest-writing code against the new schema.

Every write commits with an audit record and domain event. Store private notes and contact data in tenant records, not audit or event payloads. New tables have composite workspace references and RLS. Retention periods and legal erasure remain country-pack policy; a merge is not an erasure request. Child history tables cascade with their workspace and guest parents during privileged tenant cleanup. The self-reference from an absorbed guest to its survivor restricts individual survivor deletion: removing a target must not orphan an existing identity chain.

An operator can resolve duplicates without losing a stable link to an old identity or silently extending consent. Two concurrent decisions cannot overwrite either guest’s newer data. Restricted staff can inspect and manage the guests visible to them, while workspace-wide identity consolidation remains an administrator operation.

Identity fields shared by unrelated people can produce false-positive candidates. The reasons and persistent dismissal make that uncertainty visible. No fuzzy score is presented as certainty, and no match automatically merges records.

Unit, HTTP, PostgreSQL and built-app browser tests enforce the rules at their respective boundaries. The guest workflow guide records the concrete operations and limitations.

  • Unique e-mail or phone constraints would incorrectly collapse shared contacts.
  • Deleting the absorbed row would break old links and remove provenance.
  • Combining consent histories would imply permission that the survivor never gave.
  • Allowing property-restricted merges could mutate hidden bookings and change visibility.
  • Country-specific legal fields and automatic legal retention belong in country packs.

Contact review and merge persistence refinements

Section titled “Contact review and merge persistence refinements”

OTA snapshots are reservation evidence, rather than authoritative guest identity. A notice contains only differing fields whose revision is later than the last operator review of that field. Applying, ordinarily editing, or selecting a changed contact field in a merge records its review time; notes-only changes leave contact reviews alone. Keeping the current field records an audited dismissal and remembers that exact rejected value, including null, permanently for this identity and field. Repeated values stay suppressed across future revisions and reservations without channel-specific relay-address rules. Other fields stay independent. Notices use neutral channel/date labels and newest-first pagination. Reviews remain with their original identity during merge, just like consent evidence.

Profile shared-contact reads start from the addressed identity’s stored keys, then use equality probes for threshold checks, exact visible counts and previews. They never aggregate unrelated workspace keys. Pure key normalizers are immutable invoker functions. Execute ACLs remain explicit because the bootstrap revokes every routine by default; these helpers are listed separately from privileged functions.

Each re-point policy declares its version and optional timestamp columns. The reservation policy bumps both version and updated_at using the injected merge instant. The folio payer policy bumps the folio version only: folios have no updated_at column. Merge provenance uses that same injected instant. Per-reservation audit/event data matches ordinary updates and is written in bounded batches, retaining one evidence record per subject and transactional rollback. Adapters without bulk I/O retain the equivalent individual append behavior. A pair can be dismissed only while it is a current strong duplicate candidate; unrelated pairs return 422 without adding misleading evidence.