Guest data rights and retention
How long GuestCraft keeps a guest’s personal data, how an operator erases it or gives the guest a copy, and how a legal hold protects it during a claim. The decisions and their reasons are in ADR 0052.
Retention settings
Section titled “Retention settings”Settings → Privacy (grant workspaces.configure) holds one period per purpose:
| Purpose | Counts from | Generic default |
|---|---|---|
Guest profiles without stays (guest_profile) |
the profile’s last change | kept until a period is set |
Personal data on departed stays (stay_personal_data) |
the stay’s departure | kept until a period is set |
Booking revision archives (booking_revision_archive) |
the revision’s receipt | kept until a period is set |
Audit details (audit_pseudonymization) |
the entry | kept until a period is set |
Exports (exports) |
the export | 7 days |
Maintenance ticket photos (maintenance_photos) |
the ticket’s closing | kept until a period is set |
Message content (message_content) |
the message | 730 days, set in Settings → Messages |
The analysis sets no period for personal data, so GuestCraft deletes none of it automatically
until the workspace or its country pack sets a period. Choose the workspace’s data-protection
jurisdiction: when an installed pack has a data.retention rule for that country, its periods
replace the generic ones, and the screen shows the pack, its ruleset version and its sources.
A period set on the screen replaces both. Data that a country module collects only for an
authority follows the pack’s rule alone; the screen shows it read-only. The core collects none.
Technical periods have no setting: events and webhook delivery logs 30 days, merge tombstones’ personal data 90 days, synchronization records 30 days; outboxes are emptied once sent.
What the retention job does
Section titled “What the retention job does”Every hour (Docker worker) or at the start of each budgeted drain (Vercel), each live workspace
whose run is due gets one privacy.retention job. One run changes at most 100 rows per store
and queues the next run when a batch was full; it skips every guest under a legal hold and
records privacy.retention_applied with counts when something changed. It:
- anonymizes profiles without stays past their period, and the stays past theirs (notes, cancellation reasons, OTA contact snapshots, rooms’ free-text reasons and children’s ages), then the profile once all its stays are anonymized;
- deletes booking revision payloads whose receipt is final; quarantined revisions keep theirs;
- pseudonymizes audit details past the audit period;
- deletes the photos of maintenance tickets closed longer ago than the photo period, the stored
file first and then its row (
maintenance_photosin the counts); a ticket reported from the stay of a guest under a legal hold keeps its photos; - expires exports and removes their archives, and ends as failed an export still queued after 24 hours (its job never completed);
- anonymizes merge tombstones 90 days after the merge, keeping identifiers and pointers;
- deletes events older than 30 days without a pending webhook delivery, processed ARI changes, settled ARI sends and finished history imports older than 30 days (keeping each property’s latest for the health and history screens), and webhook signals older than 30 days.
Anonymizing a profile clears its contacts and notes and replaces its name with -; the profile
keeps its identifier, merge pointers, stays, folios and consent decisions. Contacts entered again
later are personal data again, and retention applies to them. The same holds for data written
later into a stay’s rooms (override and open-balance reasons, children’s ages), its housekeeping
tasks and the maintenance tickets reported from them, and into a profile’s consent notes or
contact reviews: retention counts again from that write.
Erasing a guest
Section titled “Erasing a guest”On the guest profile, Personal data → Erase (grant guests.erase, unrestricted members, typed
ERASE confirmation). The erasure covers the live profile and every profile merged into it. It
waits while a stay has not ended or a legal hold is active. It anonymizes the profiles, the
stays’ personal fields, the booking revision payloads, consent notes, rejected contact values,
merge snapshots, the free-text audit details, the Messages content of booking conversations,
notes on the stays’ housekeeping tasks and on maintenance tickets reported from them, photos of
those tickets, the sent or failed webhook deliveries whose stored body names the guest or a stay,
and the guest’s prepared exports.
A stay’s quarantined booking revision (Channex sent a version GuestCraft could not apply) loses
its payload, so it can no longer be retried. The erasure resolves it: its receipt becomes stale
with the reason “personal data erased; handle the booking in the channel’s extranet”, the alert
on Channels closes, the result lists it under quarantined revisions and the audit records
channex.revision_stale. Handle that booking in the channel’s extranet. A newer revision from
Channex is imported as usual.
What stays, and why: folios, postings and payments (accounting records), with the online payment records of the stay: provider payments and refunds, the card summary (brand, last four digits, expiry) of a payment, a saved card or a moved OTA card, and a saved card’s consent evidence (the provider’s identifiers are identifiers only; an OTA booking’s own card facts from Channex are cleared with the stay’s personal data); fiscal documents of a country module (GuestCraft’s core issues none); consent decisions with their time, actor and source (evidence); audit entries’ actions, actors, subjects and times; identifiers and merge links; booking identifiers and receipts (so Channex revisions are not imported again); conversations without a booking (no trusted guest link; message retention removes them). Messages that arrive after the erasure are stored normally.
Which operation to use: a guest’s erasure request (the right to erasure) is Personal data →
Erase, which includes the Messages content. Settings → Message retention → Erase a guest’s
message content (messages.erase) removes only the content of the guest’s booking
conversations and keeps the profile and stays: use it when a conversation must go but the rest
of the record stays, for example content sent by mistake.
GuestCraft cannot erase the copies held by Channex and the OTAs. Channex has no erase operation: ask Channex and each OTA directly. The confirmation and the result say so.
Known gap: purging erased content from phones is a postponed mobile task. Until then a phone keeps its cached reads for at most 72 hours (removed at sign-out and revocation), and its next read returns the anonymized data.
Access export
Section titled “Access export”Personal data → Prepare export (grant guests.export) queues a privacy.export job. It writes
one ZIP: guest-data.json (every catalog table about the guest’s identities and stays, with
public identifiers, the decrypted booking revisions, the Messages transcript, the audit
references of the guest’s identities, stays, rooms and folios, and the list of omitted columns
with their reasons) and guest-data.csv (section, record, field, value).
Message attachments are links to files Channex holds. A member holding the grant downloads the
archive once; it is deleted at download and expires after the export period. An archive over
200 MB fails; nothing is truncated.
Audit details
Section titled “Audit details”The one free-text audit detail (the reason for checking out with an open balance) is replaced by
pseudonym:v<version>:<hex>, a SHA-256 digest keyed per workspace, after the audit period or at
the guest’s erasure. The database computes it; the key never leaves it, and only
guestcraft.pseudonymize_details may write the digest. To rotate a workspace’s key, the database
owner runs select guestcraft.rotate_pseudonym_key('<workspace uuid>');: new digests use the new
version and older ones stay valid.
Legal holds
Section titled “Legal holds”Personal data → Place hold (grant guests.legal_hold) with a reason; Release, with another. One
hold is active at a time and released holds stay in the history. While active, neither erasure
nor retention touches the guest’s records.
Public operations
Section titled “Public operations”All paths are under /api/v1, with GuestCraft-Workspace-Id, and need a browser session: API
keys and organization tokens never hold these grants.
| Operation | Permission | Result |
|---|---|---|
GET /guest_data_retention |
workspaces.configure |
Settings, defaults with their source, effective periods, technical periods |
PUT /guest_data_retention |
workspaces.configure |
Saved settings; If-Match required |
GET /guests/{id}/legal_holds |
guests.erase, guests.export or guests.legal_hold |
Holds, newest first |
POST /guests/{id}/legal_holds |
guests.legal_hold |
The new hold; 409 guest.legal_hold_active when one is active |
POST /guests/{id}/legal_holds/{hold_id}/release |
guests.legal_hold |
The released hold |
POST /guests/{id}/erase |
guests.erase |
The completed request, the new guest version, retained and upstream; If-Match required |
POST /guests/{id}/data_exports |
guests.export |
A queued request |
GET /guest_data_requests |
guests.erase or guests.export |
Requests, newest first; guest_id filters to one guest’s chain |
GET /guest_data_requests/{id} |
guests.erase or guests.export |
One request |
POST /guest_data_requests/{id}/download |
guests.export |
The ZIP, once |
The OpenAPI descriptions list each operation’s problems. Erasure answers 409 guest.erasure_blocked with reason active_stay (and the reservations) or legal_hold, and
409 guest.absorbed with the survivor for a merged profile. A download answers 409 privacy.export_unavailable with reason not_ready, downloaded, expired, erased or
failed.
Adding personal data
Section titled “Adding personal data”Every column of a table with workspace_id has an entry in packages/db/src/personal-data.ts:
a personal column with its purpose, erasure rule and access rule, or the list of other columns.
The PostgreSQL catalog test fails until a new column is classified. A module whose data a
column rule cannot reach (encrypted content, files) implements PersonalDataModule of
@guestcraft/core/privacy and is composed in packages/server.