Skip to content

Anonymization — developer guide

Chill anonymizes the file of a person once nothing has happened on it for a configurable delay. This page describes the pipeline, the four extension points every bundle is expected to implement for the data it owns, and the pitfalls.

Configuration

chill_person:
    # ISO 8601 duration;
    delay_before_anonymization: P2Y

The delay is subtracted from "now" to obtain the pivot date. Events occurring after the pivot date keep a person out of the candidate list.

Managing the anonymization requires the role CHILL_PERSON_ANONYMISATION_MANAGER (AnonymisationManagerVoter). It is not scoped, but the candidate list and the launch are restricted to the centers where the user holds the role (AnonymizationCenterResolver).

The pipeline

                  ┌─ sync-anonymization-list (cron, weekly) ─┐
                  │  AnonymizationListCandidateManager       │
   candidates ◄───┤  ∀ AnonymizationQueryCandidateProvider   │
                  └──────────────────────────────────────────┘
                                    │
              a manager launches the anonymization (UI)
                                    │
                     AnonymizationLauncher::launch()
                                    │  dispatches one AnonymizationRequest per person
                                    ▼
                     AnonymizationRequestHandler
                                    │
                   AnonymizationProcessorManager::anonymize()
                     ┌──────────────┴───────────────┐
                     │ ONE transaction:             │
                     │ ∀ AnonymizationProcessor     │
                     └──────────────┬───────────────┘
                                COMMIT
                                    │  (outside the transaction)
                     ∀ AnonymizationPostProcessor
                                    │  dispatches AnonymizationCascadeRequest,
                                    │  only for the entities whose participants
                                    │  are now all anonymized
                                    ▼
                  AnonymizationCascadeRequestHandler
                                    │  back to the processor manager,
                                    │  for the period / household / ticket

Both messages are routed to the async transport (in-memory in the test environment).

1. Building the list of candidates

SyncAnonymizationListCronJob (key sync-anonymization-list, runs on sundays) calls AnonymizationListCandidateManager::syncAnonymizationList(). The manager assembles every AnonymizationQueryCandidateProviderInterface into a single statement:

  • a person is inserted into chill_person.anonymization_candidate when NOT EXISTS (…) holds for every provider;
  • a candidate is deleted when EXISTS (…) holds for any provider, or when the person is already anonymized.

The list is therefore rebuilt from scratch at every run: a person who becomes active again leaves the list on her own.

2. Launching

A manager triggers AnonymizationLauncher::launch() from the candidate list. It dispatches one AnonymizationRequest per person that may be anonymized immediately — i.e. candidates with no delay, or whose delay has passed. The anonymization manager (a user) may postpone a candidate by setting a delay on it (AnonymizationCandidateDelayService).

The handler re-checks the conditions when it consumes the message: the state may have changed between the launch and the consumption.

3. Anonymizing

AnonymizationProcessorManager::anonymize() collects the SQL of every processor supporting the entity and runs it in one transaction. It is all-or-nothing: if a single query fails, the person is not anonymized at all and reappears in the list at the next synchronization.

4. Cascading

An accompanying period, a household or a ticket may only be anonymized once all of its participants are anonymized — which cannot be decided inside the transaction anonymizing a single person. Hence the post-processors: after the COMMIT they dispatch one AnonymizationCascadeRequest per linked entity. Most of these messages are abandoned by their handler because some participants are still active; the message dispatched after the last participant is the one that triggers the anonymization.

The three extension points

All three are collected by autoconfiguration — implement the interface and put the class in a directory loaded by the bundle's services.yaml; there is no tag to declare.

Interface Tag Answers
AnonymizationQueryCandidateProviderInterface chill_person.anonymization_query_candidate_provider "did something happen on this person's file since the pivot date?"
AnonymizationProcessorInterface chill_person.anonymization_processor "which SQL anonymizes my data for this entity?"
AnonymizationPostProcessorInterface chill_person.anonymization_post_processor "which linked entities may now be anonymized in cascade?"

Each bundle implements them for the data it owns. ChillPersonBundle must not know about tickets, activities or budgets.

Candidate providers

The provider returns a SQL fragment, not a standalone query: it is embedded into NOT EXISTS (…) / EXISTS (…) by the manager, and must reference the outer alias person_candidate. Parameters are positional (? / setParameter(0, …)), because the manager concatenates the parameters of all providers in order.

public function provideNotExistsQuery(\DateTimeImmutable $pivotDate): QueryBuilder
{
    return $this->connection->createQueryBuilder()
        ->select('1')
        ->from('chill_ticket.person_history', 'person_history')
        ->where('person_history.person_id = person_candidate.id')   // ← the outer alias
        ->andWhere('person_history.startdate > ?')                  // ← positional
        ->setParameter(0, $pivotDate, Types::DATETIME_IMMUTABLE);
}

Warning

Adding a provider makes the candidate list more restrictive, never less: a person is a candidate only when every provider agrees. A provider whose query is too broad silently empties the list.

Events happening inside an accompanying period are not read table by table: they come from view_chill_person_accompanying_period_info, through AccompanyingPeriodInfoQueryCandidateProvider. That view is the same source which marks a period as inactive, so a period still considered active always keeps its participants out of the list. Add a new kind of period event to the view, not to a provider of its own.

Processors

Implementations are named …Anonymizator and live in <bundle>/Service/Anonymization/. They return QueryBuilder objects, executed by the manager — a processor never executes anything itself. See the anonymization-processor agent skill for the full conventions, the SQL recipes and the traps (embeddable column names, NOT NULL columns, database triggers).

The intent is anonymization, not deletion: free text is emptied, dates are truncated to the quarter, amounts are rounded — the rows stay, so statistics remain meaningful.

Detecting a cascade which never happened

The cascade is requested once, by the post-processor, right after the person's COMMIT. When that dispatch fails, the person stays anonymized and nothing reports it: the message is retried, AnonymizationRequestHandler finds the person already anonymized and abandons, so nothing reaches the failed queue. The warning it logs on that branch is the only trace.

There is no cron sweeping for it. Run this by hand after a large anonymization — it lists the accompanying periods which should have been anonymized and were not:

SELECT p.id FROM chill_person_accompanying_period p
WHERE p.anonymizationdate IS NULL
  AND EXISTS (SELECT 1 FROM chill_person_accompanying_period_participation pa
              WHERE pa.accompanyingperiod_id = p.id)
  AND NOT EXISTS (SELECT 1 FROM chill_person_accompanying_period_participation pa
                  JOIN chill_person_person person ON person.id = pa.person_id
                  WHERE pa.accompanyingperiod_id = p.id
                    AND person.anonymizationdate IS NULL);

The same shape works for chill_person_household (through chill_person_household_members) and for chill_ticket.ticket (through person_history and caller_history). A cascade found this way is replayed by anonymizing any of its participants again, or by dispatching an AnonymizationCascadeRequest by hand.

Documents

Documents are not deleted by the processors. Their StoredObject is switched to the status to_delete, and the content is removed asynchronously by the cron job delete-stored-object-content. The row itself is kept so the document (without its stored object content) stays attached to the person file.

Every processor holding documents needs the same UPDATE, and only differs by the way it reaches the stored objects. Do not repeat it: inject Chill\DocStoreBundle\Service\Anonymization\StoredObjectAnonymizationHelper and pass the sub-query selecting their ids.

private function markDocumentsToDelete(?int $personId): QueryBuilder
{
    return $this->storedObjectHelper
        ->anonymizeWhere('SELECT object_id FROM chill_doc.person_document WHERE person_id = :personId')
        ->setParameter('personId', $personId, ParameterType::INTEGER);
}

The sub-query is inlined in the SQL as-is: it must be written by a developer, never built from a value coming from the outside, and every value it compares must go through a named parameter. The helper binds emptyTitle and storedObjectToDelete itself, so a sub-query must not reuse those two names.

Testing

Two abstract bases assert that the generated SQL is valid against the real schema — they do not assert on the result set:

  • Chill\PersonBundle\Test\Anonymization\AbstractAnonymizationQueryCandidateProviderTestCase — implement getProvider(); the fragment is wrapped exactly as the manager does and executed.
  • Chill\PersonBundle\Test\Anonymization\AbstractAnonymizationProcessorTestCase — implement getProcessor() and getSupportedEntities() (use the findOneEntity() helper to pick entities from the fixtures); the queries run inside a transaction which is rolled back.

Each processor has its own test. Extend the base of the entity it supports — they already implement getSupportedEntities() and getUnsupportedEntities(), so only getProcessor() is left to write:

  • Chill\PersonBundle\Test\Anonymization\AbstractAnonymizationProcessorForPersonTestCase
  • …ForAccompanyingPeriodTestCase, …ForHouseholdTestCase
  • Chill\TicketBundle\Test\Anonymization\AbstractAnonymizationProcessorForTicketTestCase

Post-processors are tested against the in-memory Messenger transport (self::getContainer()->get('messenger.transport.async')), asserting the dispatched AnonymizationCascadeRequest messages.

Warning

A test which anonymizes for real must run inside a transaction it rolls back: the anonymization is irreversible and the fixtures are shared with the rest of the suite.

Relevant fixtures:

  • LoadAnonymizablePeople — persons with a dormant file, spread over two centers. They are the ones the synchronization is expected to find;
  • LoadAnonymizationCandidate — one candidate per person above, a part of them delayed, so the list and its filters can be tested without running the cron. The synchronization keeps these rows: it only inserts the candidates it is missing, and deletes those whose file became active again.