0052. Guest data rights and retention through one personal-data catalog
Questi contenuti non sono ancora disponibili nella tua lingua.
- Status: accepted
- Date: 2026-11-01
- Deciders: GuestCraft maintainers
Context
Section titled “Context”The domain analysis (chapter 5, “Retention and data
size”) asks installers to choose retention periods by purpose, starting from country-pack
defaults, keeps data collected only for an authority separate, and sets technical periods:
events and webhook delivery logs 30 days, tombstones 90 days, sync_job 30 days and outboxes
cleared after the push. Chapter 6 gives the country
packs a DataPolicy: purpose-specific retention, with the common deletions, exports and
controls in the core. Before this decision GuestCraft had one retention setting (Channex
Messages content, ADR 0044) and one erasure (the same content).
Guest profiles, stays, booking revision archives, audit details and every technical store kept
their rows forever, and a guest could not obtain a copy of their data.
Decision
Section titled “Decision”One catalog for every store
Section titled “One catalog for every store”packages/db/src/personal-data.ts classifies every column of every table with a
workspace_id. A personal column states its purpose, its erasure rule (clear, redact,
pseudonymize, delete_row, keep_legal_obligation, not_guest_linked) and its access rule
(export, or omit_with_reason with the reason); every other column is listed as holding no
personal data. A table that holds rows about one guest declares the SQL that selects them
(by guest identity, by the guest’s reservations, or by the TypeIDs that audit subjects and
webhook payloads carry). Erasure, the retention passes and the access export are all built from
this catalog. A real PostgreSQL test enumerates the columns through pg_catalog and fails on a
column without an entry or an entry without a column, so a later module (child ages, payment
cards, check-in documents) classifies its columns before it ships. The catalog also classifies
the organization tables whose rows a workspace reads and that can hold personal data: the
receivables counterparty directory, whose contacts, billing details and tax identifiers are
business contacts (not_guest_linked, kept while the counterparty is), and exchange rates, whose
reason is about a rate’s source. The test checks their columns as it checks a workspace table’s.
The public contract is @guestcraft/core/privacy (contract.ts): DataPurpose,
PersonalDataColumn, PersonalDataModule, RetentionDefault and DataPolicy. A module whose
data column rules cannot reach registers hooks that run in the erasure’s or the export’s
transaction. Two exist: Messages (its encrypted content, through the redaction of ADR 0044,
which its own erasure endpoint also uses) and maintenance photos (only the photos of tickets
reported from a housekeeping task of the guest’s reservation; other photos show rooms and follow
their own retention). A module’s erase receives the acting principal (Messages audits each
cancelled reply) and returns the rows it changed; a module may also export. The erasure rule
not_guest_linked marks operational text that is not about one guest. Notes typed on a
housekeeping task of the guest’s stay, and on a maintenance ticket reported from such a task, are
about the guest: they are exported and erased with the stay.
Purposes, defaults and the country-pack hook
Section titled “Purposes, defaults and the country-pack hook”The purposes with a workspace setting are guest_profile (profiles without stays, counted from
the last change), stay_personal_data (counted from the stay’s departure), booking_revision_archive
(from receipt), audit_pseudonymization (from creation), exports (from creation) and
maintenance_photos (photos of maintenance tickets, counted from the ticket’s closing).
message_content stays the Messages setting of ADR 0044; the settings screen shows it and links
to it. A pack names its own authority.<name> purposes; a module names module.<name>.
Packs give defaults as a dated data.retention rule (ADR 0015): per purpose, whole days or
null (keep until erasure) and the instant they count from, which must be the purpose’s.
DataPolicy.retention(day) reads the version in force; a gap or no pack falls back to the
generic defaults. The workspace chooses its data-protection jurisdiction in the retention
settings; that country selects the pack. Data protection follows the controller, not each
property, and properties have no country yet.
The analysis sets no period for the personal purposes, so their generic default keeps the data until a workspace or a pack sets a period; ticket photos are kept until a period is set too. Exports expire after 7 days. Message content keeps the 730-day Messages default that ADR 0044 ships; a pack’s message period replaces it. The fictitious test pack sets periods; the Italian pack has no sourced rule yet and keeps the generic defaults until an Italian professional supplies one. Data collected only for an authority stays out of the workspace settings: the screen shows the pack’s rule read-only, and the country module that stores such data applies it. The core stores none today.
Erasure
Section titled “Erasure”POST /guests/{id}/erase (grant guests.erase, unrestricted members, browser only) takes the
workspace’s guest-merge gate exclusively, refuses an absorbed identity with its survivor
(ADR 0045), resolves the survivor’s whole chain (every identity merged into it), locks it and
checks the survivor’s version. It refuses while a stay of the chain has not ended (cancel or
complete it first) or while a legal hold is active. In one transaction it then:
- anonymizes the profiles of the chain (name
-, contacts and notes cleared, search keys recomputed,erased_atset), keeping their identifiers and merge pointers; - clears the reservations’ notes, cancellation reasons, OTA contact snapshots, the rooms’ free-text reasons and the children’s ages, and bumps each reservation’s version (the prices frozen on the nights stay);
- deletes the archived booking revision payloads of those stays, keeping the non-personal revision and receipt rows that make Channex imports idempotent;
- resolves the stays’ quarantined revisions, whose payload is now gone and which can no longer
be retried: each receipt becomes stale with the reason “personal data erased; handle the
booking in the channel’s extranet”, is audited
channex.revision_stale, its alert closes and the result counts it; a newer revision from Channex supersedes it as usual; - clears consent notes (purpose, decision, time, actor and source stay as evidence), rejected contact values and merge provenance snapshots;
- pseudonymizes free-text details of the chain’s audit entries and events;
- deletes the sent or failed webhook deliveries whose stored body names one of the chain’s identifiers, so a redelivery cannot replay them; a pending delivery still goes out (its body holds identifiers only) and is purged with the logs after 30 days;
- ends the chain’s open exports and removes their archives;
- runs the module hooks (Messages content, guest-linked photos);
- records a completed request with counts per store, and audits
guest.erased.
Folios, postings, payments and their payer details are accounting records and stay; GuestCraft’s
core issues no fiscal documents, and a country module keeps its own under its law. The result
lists what stayed (retained) and what GuestCraft cannot erase (upstream: Channex and the
OTAs, because Channex has no erase operation); the confirmation dialog states both before the
operator types ERASE, and tells them to ask Channex and each OTA. Conversations without a booking
have no trusted guest link (ADR 0044) and are left to message retention; messages that arrive
after the erasure are stored normally and the erasure point is recorded.
Storage notes
Section titled “Storage notes”- Rows carry a marker of their anonymization (
guests.erased_at,reservations.personal_data_removed_at,guest_merge_provenance.redacted_at,audit_log.pseudonymized_at): a pass selects rows whose marker is unset, so a row whose values already look anonymous is still marked once and never selected again. Anonymizing a profile also bumps its version, so an editor opened before cannot save the old values back. - The app role still never reads merge snapshots (ADR 0045): it redacts them by their marker. Only the worker reads them, for the access export.
- Messages’ retention row created by its scheduler claim has version 1; until an operator saves a period (version 2 and later), the purge and the settings read use the jurisdiction default.
- Two partial indexes serve the passes: audit entries by subject, and folios by guest payer.
- Contacts written again into an anonymized profile, or personal fields into a stay whose data was removed (an operator’s edit, a later OTA revision), clear the row’s marker in a trigger, so retention applies to them again. So does personal data written into a dependent row: a room’s override or open-balance reason or children’s ages, also on a room added later, a note on a housekeeping task of the stay or on a maintenance ticket reported from one restarts the stay’s retention; a consent note, a dismissed contact value or a merge snapshot restarts the profile’s. Clearing such a value restarts nothing.
- Exports end through conditional transitions: expiry only from
ready, the 24-hour stall only fromqueued, each on rows it locks (SKIP LOCKED) and rechecks. A transition reports whether it won; only the winner of an expiry or an erasure removes the archive, so a download, a late preparation or an erasure running at the same time is never overwritten. - A legal-hold change takes the guest gate exclusively. A retention pass holds it shared for its whole transaction, so a hold waits for a running pass and every later pass sees it.
- A retention job of a workspace archived after the job was queued does nothing.
- Retention locks candidate rows with
SKIP LOCKEDand never waits for an operator. A booking created for a profile at the very instant a pass anonymizes it is not detected; the window is one statement, and the profile keeps its identifier.
Legal holds
Section titled “Legal holds”A member holding guests.legal_hold places a hold on a guest with a reason and releases it with
another; one hold is active at a time and released holds stay as history. The hold’s reasons are
kept as evidence (keep_legal_obligation); the audit records only the hold’s identifier. An
active hold on any identity of a chain blocks its erasure and every retention pass over its
profile and stays.
Access export
Section titled “Access export”POST /guests/{id}/data_exports (grant guests.export) queues a request; the privacy.export
job (payload: identifiers only) reads every catalog table whose rows are about the chain, with
public TypeIDs, plus the decrypted booking revisions and the Messages transcript. Message
attachments are links to files Channex holds: the export lists the links and does not fetch
the files. It stores one ZIP with guest-data.json (the document, with the omitted columns and
their reasons) and guest-data.csv (one value per line: section, record, field, value) through
the FileStore port, so object storage can serve it later. An archive over 200 MB fails the
request clearly; nothing is truncated. Any member holding the grant downloads it once
(POST /guest_data_requests/{id}/download, a transition, because the download consumes it); the
archive is deleted in the same transaction and expires after the export period (7 days by
default). An export whose job never completes (it failed for good) ends as failed
(not_prepared) after 24 hours, longer than its retries and the daily cron of hosts without a
resident worker. Requests, preparation, download, failure and expiry are each audited per
request. A folio paid by another guest names that payer by identifier only: billing details exist
for company payers alone. Audit references list entries whose subject is one of the guest’s
identities, stays, the stays’ rooms or the folios of those stays or paid by the guest.
Retention passes and technical periods
Section titled “Retention passes and technical periods”privacy.dispatch (hourly on the Docker worker, and at the start of every budgeted drain on
Vercel) lists live workspaces of the installation whose run is due; archived workspaces are
excluded by the discovery function and by the restrictive workspace_active policy. Each claims
an hour-long window and queues privacy.retention, which applies at most 100 rows per store in
one transaction, never touches a guest under a legal hold, audits privacy.retention_applied
with counts when something changed, and queues its continuation when a batch was full:
- profiles without stays and stays past their period, with the profile anonymized once all its stays are;
- booking revision payloads whose receipt is final (
appliedorstale); quarantined and non-routable revisions keep theirs, because a retry needs them; - audit details older than the audit period pseudonymized;
- expired exports, and exports still queued after 24 hours ended as
not_prepared; - the photos of maintenance tickets closed longer ago than the photo period (the stored file
through the
FileStoreport, then its row); a ticket reported from the stay of a guest under a legal hold keeps its photos; - merge tombstones: an absorbed profile’s personal data and the merge snapshots 90 days after the merge, keeping identifiers and pointers;
- domain events older than 30 days (plus one day of margin) without a pending webhook marker;
- synchronization records: processed ARI changes (the outbox, once nothing supersedes into them), settled ARI sends older than 30 days except each property’s latest 20 for the health screen, finished history imports older than 30 days except each property’s latest 10, and webhook signals older than 30 days. Revision and receipt rows stay: they are the feed’s idempotency.
Webhook delivery logs already expire after 30 days in the delivery job (ADR 0040), the webhook outbox is deleted when it fans out, the Messages outbox keeps no content after a send, and pg-boss deletes completed jobs after 7 days (14 days for queued ones). Job payloads hold identifiers only: the job catalog builds them from identifier, enum, integer and date fields, and a test runs every job’s payload schema against personal values.
Audit evidence
Section titled “Audit evidence”Audit entries keep their action, actor, subject identifiers and time. Their details hold
identifiers and codes, except one free-text value: the operator’s reason for checking out with an
open balance (folio.open_balance_confirmed, also in its event). Pseudonymization replaces it
with a SHA-256 digest keyed per workspace, after the audit period or at the guest’s erasure.
Only one security-definer function, guestcraft.pseudonymize_details, writes digests. It refuses
a workspace outside the caller’s app.workspace_ids, computes each digest in the database from
the old value and the workspace’s key, and records the key’s version in the value
(pseudonym:v1:<hex>). Keys live in guest_data_pseudonym_keys, which no runtime role can read:
the key never leaves the database. The app and worker roles have no update grant on audit entries
or events, and the triggers accept a change only when the new detail is the digest of the old
value under one of the workspace’s key versions. Exactly that change is allowed: on an audit
entry, once, the reason of that action with pseudonymized_at set and every other column and
detail unchanged; on its event the same detail; on a merge provenance row only its redaction
(empty snapshots and the marker). guestcraft.rotate_pseudonym_key(workspace), run by the
database owner, adds a version used for new digests; digests of older versions stay valid.
Retained evidence is visible under the existing Activity permission.
Permissions
Section titled “Permissions”guests.erase, guests.export and guests.legal_hold belong to administrators by default, are
refused to property-restricted members (a guest’s stays span properties) and are never granted
to API keys or organization tokens: every operation is browser administration (ADR 0041).
Retention settings require workspaces.configure.
Consequences
Section titled “Consequences”An operator can set retention per purpose, see where each period comes from, erase a guest with a clear statement of what stays and what only Channex and the OTAs can erase, give a guest a machine-readable copy of their data, and protect a guest’s records during a claim. Technical tables stop growing without bound.
The catalog adds a review step to every schema change: a new column fails the catalog test until it is classified. That is intended.
Remaining limits: phones keep cached reads of erased content until their 72-hour cache expires, sign-out or revocation (purging them is a postponed mobile task); the Italian pack has no retention rule; no authority-only data store exists yet; attachments are exported as links; upstream erasure is the operator’s request to Channex and each OTA.
Alternatives considered
Section titled “Alternatives considered”- Per-module erasure functions without a catalog. Rejected: nothing would notice a new column, and erasure and export would drift apart.
- Retention defaults as code in the core, per country. Rejected by ADR 0015: national rules live in packs, with sources and dates.
- Generic periods for every purpose. Rejected: the analysis sets none, and an invented period would delete data without a legal or product basis.
- Deleting guests and reservations. Rejected: identifiers keep merges, folios, Channex idempotency and audit evidence consistent; anonymization removes the personal data.
- Fetching attachment files into the export. Rejected for now: it adds upstream calls to the export job; the export lists the links.
- An e-mailed export link. Rejected: GuestCraft has no e-mail; an authorized member downloads it in the app.