← Para municipios neerlandeses

Privacy (AVG / GDPR)

What Goooy gives an organization to meet the AVG: a record of processing to copy into its own register, the list of everyone who receives personal data, a register of data-subject requests with scan, export and erasure, and erasure that reaches object storage. Built for Dutch municipalities; nothing in it is Dutch-only.

The paperwork that goes with it is in Dutch, because the gemeente fills it in:

  • avg/dpia-model.md: a DPIA for the suite, following the Model DPIA Rijksdienst v3.0, with the parts Goooy can answer filled in.
  • avg/verwerkersovereenkomst-bijlagen.md: Bijlage 1 (personal data, subjects, subprocessors) and Bijlage 2 (security measures) of the VNG/IBD Standaard Verwerkersovereenkomst Gemeenten (v2.53, December 2025).

Who is controller and who is processor

The organization (the gemeente) is always the controller. Goooy itself is software. Whoever runs the deployment is the processor.

Who runs it Processing agreement
The gemeente itself, on its own or rented infrastructure With the infrastructure provider (the provider is the processor).
A shared-services organization (GR) for several gemeenten Between each gemeente and the GR.
A hosting partner or Goooy as a managed service Between the gemeente and that party; the provider’s own suppliers are its subprocessors.

PRIVACY_PROCESSOR_NAME and PRIVACY_PROCESSOR_CONTACT put that party in the register export.

Record of processing (art. 30)

Admin → Privacy (AVG) → Verwerkingsregister, or GET /api/v1/admin/privacy/register?lang=nl|en&format=json|csv.

One row per processing activity the suite performs: accounts and sign-in, mail, calendar, contacts, files and documents, chat, video meetings, forms, the shared inbox, e-signatures, the AI assistant and agents, and the security log. For each: purpose, data subjects, categories of personal data, recipients, transfers outside the EU, retention and whether it is in use (with a record count). The texts are a starting point written once in Dutch and English; the gemeente adjusts the purposes to its own tasks.

What the export works out itself:

  • In use: counted in this organization, so an unused module shows as such.
  • Recipients: the integrations that are switched on and point outside the deployment (from the same inventory as the data-residency report).
  • Transfers outside the EU: “No” only when DATA_RESIDENCY=eu-strict is on; otherwise “not enforced, check each recipient”. The app can’t know where a host is.
  • Retention: the enabled retention policies that apply, otherwise the default.

Voice calls with an AI agent add one thing to read: with ELEVENLABS_API_KEY set, ElevenLabs shows up as the recipient Voice agents (ElevenLabs) on the Chat and Video meetings rows, because the call audio and the agent’s spoken answers are processed there (ElevenLabs is a US company; the engines ask it to keep nothing, and zero-retention and the isolated EU environment are Enterprise features). A processing agreement and a DPIA are the operator’s to arrange before an organization turns the feature on (agents.md). The words of a voice turn are stored with the agent’s run like any chat-triggered run (and follow the same retention); Goooy stores no audio.

The CSV is semicolon-separated UTF-8 with a BOM, which Dutch Excel opens directly. Downloading it is an export entry in the audit trail.

Recipients and subprocessors

GET /api/v1/admin/privacy/subprocessors: what the operator declared plus what the app detects. The app can’t see the hosting provider, the backup location or a support desk, so the operator lists them:

PRIVACY_SUBPROCESSORS='[{"name":"Hetzner Online GmbH","purpose":"Hosting (compute, storage)","location":"Falkenstein, DE"},
                        {"name":"Example Backup B.V.","purpose":"Off-site backups","location":"Amsterdam, NL"}]'

Helm: config.privacy.subprocessors (the same JSON), config.privacy.processorName, config.privacy.processorContact.

Data-subject requests (art. 15-21)

Admin → Privacy (AVG) → Verzoeken van betrokkenen. An ORGADMIN works in their own organization; a SYSADMIN may pass ?orgId=.

  1. Register the request: kind (access, erasure, rectification, restriction, portability, objection), the person’s email address, an optional name and the gemeente’s own reference, and the date it arrived. The deadline is one month later (art. 12(3); 31 January runs to 28 February). Extend adds two months once and can’t be undone. Overdue and due-within-a-week requests are marked.

  2. Scan finds everything in the organization tied to the address: the staff account (by address or alias), mail from or to it in every mailbox (From, Sender, To, Cc, Bcc, Reply-To), contacts, meetings the person organises or is invited to, form responses and helpdesk tickets. For staff also chat messages, files and security-log entries. Messages in a mailbox under a legal hold are counted separately.

  3. Export (access, portability) downloads a zip: overzicht.json, every message as .eml (the newest 2,000; the overview says when there were more), contacts.json, calendar.json, form-responses.json, helpdesk-tickets.json, and for staff account.json, chat-messages.json, files-index.json and security-log.json. Quarantined attachments are replaced by a note. File contents are not in it: the person downloads their own files while the account exists. Review the export before handing it over: mail about the person can contain other people’s data (art. 15(4)).

  4. Erase per scope, after typing the person’s address:

    Scope What happens
    Mail Every message from or to the address is deleted, in every mailbox, except mailboxes under a legal hold. Native IMAP clients follow through the IMAP sync.
    Contacts Contacts carrying the address are deleted.
    Meetings The person is removed from attendee lists; the meeting stays.
    Form responses Deleted; the response count follows. Rows already mirrored into a form’s spreadsheet stay in that document (the result says how many).
    Helpdesk tickets The requester’s address and name, and the AI summary, are removed; the ticket stays for the helpdesk’s figures. The ticket’s mail is the Mail scope.
    Account The staff account and everything it owns. Refused for your own account, for an account under a legal hold, and for a SYSADMIN unless you are one.

    Everything is refused on a closed request, and an account refusal is checked before anything else is deleted.

  5. Close as done or rejected. Each step is kept on the request (actions) and in the audit trail (privacy.request.create/scan/export/erase/update).

Mail and the Archiefwet. Mail can be an archival record the gemeente must keep; erasing it is the gemeente’s decision under its selection list, and the dialog says so. That’s also why mail isn’t selected by default.

What erasure does not touch:

  • The security log keeps its entries, including the address in their detail. Keeping them is a legal duty (BIO2 logging, art. 17(3)(b)); the entries expire with the audit-log retention policy.
  • Legal holds: held mailboxes and accounts are skipped or refused. Release the hold first when the matter is closed.
  • Backups: see below.

Erasure reaches object storage

Attachments, file versions, signature images and the attachments of preserved legal-hold items live in S3, not in PostgreSQL. Statement-level AFTER DELETE triggers on Attachment, FileNode, FileVersion, SignatureSigner and LegalHoldItem write each key that lost its row to BlobTombstone. They fire for every delete path (REST, JMAP, EAS, MAPI, retention sweeps, the cascade from a deleted user or organization) without touching call sites. lib/blobGc.ts then, every BLOB_GC_INTERVAL_MS (15 minutes):

  • takes tombstones older than BLOB_GC_GRACE_MS (1 hour),
  • checks that no row references the key any more (a copy can share a key; a message under a legal hold is preserved with its attachment keys),
  • deletes the object, or drops the tombstone when the key is still in use,
  • keeps the tombstone of a failed delete for the next run.

The page shows how many objects are still waiting. goooy_blob_gc_objects_total (result = deleted/kept/failed) and the blob-gc job metrics are exported. A SYSADMIN can run the janitor at once: POST /api/v1/admin/privacy/erasure-backlog/runs?graceMs=0.

Objects orphaned before this migration are not tombstoned. A deployment that ran older versions still has them in its bucket; a fresh installation has none.

Backups

Erased data stays in backups until those expire. State the period in the DPIA and the processing agreement.

  • SQL dumps: kept backup.retentionDays (7 by default).
  • Object mirror: backup.objectStorage.removeDeleted=false (the default) keeps every deleted object in the mirror indefinitely. That protects against a malicious deletion but means erased attachments and files are never erased from the backup. For a deployment under the AVG, version the destination bucket, set removeDeleted=true, and give the bucket a lifecycle rule that expires noncurrent versions after the same number of days as the dumps. A deletion then reaches the backup after that period and not before. See operations.md.

Regression

  • apps/api/test/smoke-privacy.mjs (66 checks, in run-all.mjs): register and CSV, subprocessors, deadlines, scan, the export zip, erasure with a legal hold, the bucket checked directly before and after the janitor, account erasure, extension and closing, audit entries.
  • apps/web/e2e/admin-privacy.spec.ts: the page, the request flow and axe at desktop and phone width.

Configuration

Variable Default Meaning
BLOB_GC_INTERVAL_MS 900000 How often the janitor runs; 0 turns it off.
BLOB_GC_GRACE_MS 3600000 Minimum age of a tombstone before its object is deleted.
PRIVACY_SUBPROCESSORS [] Declared subprocessors, JSON [{name, purpose, location}].
PRIVACY_PROCESSOR_NAME empty The party running the deployment, for the register.
PRIVACY_PROCESSOR_CONTACT empty Its contact for privacy matters.

Register additions (round 7). The art. 30 register has a “Woo-verzoeken” activity (requesters’ names and addresses, the documents collected), and the organization’s zaaksysteem is listed as a recipient of mail and files while the connection is enabled.