Consent Service
The Consent service records what each member has consented to and accepts GDPR Article 17 right-to-erasure requests. It owns three tables: current consent state, an audit log of every change, and deletion requests. Owned by the consent service, container olly-consent, listening on :8080 inside the container (mapped to host 127.0.0.1:4012). Persistence is the dev-2 Postgres via DATABASE_URL.
Status: partial
Erasure is not a cross-service orchestration today. It is a local, in-process polling job that deletes or masks only the consent service's own three tables. There is no Temporal, no fan-out to other services, and no internal deletion endpoint on any service. The doc below describes what is built; planned-but-absent behaviour is marked inline.
Field reference: the catalog (catalog.dev.hiolly.com) holds column-level detail. This page is the narrative; the column tables below are kept because the schema is small and load-bearing.
What it owns
| Table | Role |
|---|---|
consent_records | Current consent state per (party_locator, consent_type): upsert only, unique on the pair |
consent_audit | Append-only log of every consent state change (see caveat: not DB-enforced) |
deletion_requests | Right-to-erasure requests, one active per party at a time |
outbox | Transactional outbox drained to Kafka by a background worker |
It does not enforce consent at request time (each consuming service does its own check) and it does not delete other services' data.
Consent types
The five valid consent_type values come from domain/enums.go and are enforced by the handler validator. All consent records default to granted = false; there is no mandatory or default-on type, and no terms-version or channel is stored.
| Constant | Notes |
|---|---|
NHS_DATA_SHARING | Data shared with NHS systems |
WEARABLE_SYNC | Wearable / fitness device data ingestion |
EMPLOYER_WELLBEING_REPORTING | Aggregate wellbeing reports to employer |
RESEARCH_PARTICIPATION | Anonymised data for research programmes |
MARKETING_COMMUNICATIONS | Email / SMS / push marketing |
A PUT with any other consentType returns 400 listing the valid set.
API routes
All routes sit behind JWT validation when KEYCLOAK_JWKS_URI is set (otherwise open, for local runs). There is no internal-token path and no /internal/* route.
| Method | Path | Request body | Response |
|---|---|---|---|
GET | /consent/{partyLocator} | : | 200 flat array [{consentType, granted}] |
PUT | /consent/{partyLocator} | {consentType, granted, changedBy, reason} | 200 {partyLocator, consentType, granted}; MEMBER role only (see below) |
GET | /consent/{partyLocator}/audit | : | 200 flat array of audit rows (see shape below); no pagination, no filter |
POST | /consent/{partyLocator}/deletion | {deleteType: SOFT|HARD, requestedBy: MEMBER|EMPLOYER, reason?} | 201 {id, status: "PENDING"}; 409 if a request is already active |
GET | /consent/{partyLocator}/deletion/{id} | : | 200 the bare deletion_requests row; 404 if not found |
PUT is restricted to the MEMBER role: when JWT claims are present and role != "MEMBER" the handler returns 403. It updates exactly one consent type per call (single object, not a batch) and emits one consent.changed outbox event in the same transaction as the upsert and audit insert.
The audit response items are {consentType, oldValue, newValue, changedBy, changedByType, reason?, occurredAt}, returned as the full list for the party.
Database
PKs are uuid on all three tables. Columns below match the migrations exactly.
consent_records
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id | uuid | no | PK | |
party_locator | text | no | ||
consent_type | text | no | one of the five valid types | |
granted | boolean | no | false | current state |
created_at | timestamptz | no | now() | |
updated_at | timestamptz | no | now() |
Unique constraint uq_consent_party_type on (party_locator, consent_type).
consent_audit
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id | uuid | no | PK | |
party_locator | text | no | ||
consent_type | text | no | ||
old_value | boolean | yes | NULL on first grant for a type | |
new_value | boolean | no | state after this change | |
changed_by | text | no | actor id from the request body | |
changed_by_type | text | no | written as MEMBER | |
reason | text | yes | member-supplied reason | |
occurred_at | timestamptz | no | now() |
deletion_requests
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id | uuid | no | PK, returned to the caller | |
party_locator | text | no | ||
delete_type | text | no | SOFT (mask) or HARD (delete rows) | |
status | text | no | PENDING | PENDING → PROCESSING → COMPLETED / FAILED |
requested_by_type | text | no | MEMBER or EMPLOYER | |
reason | text | yes | nulled out by a SOFT erasure | |
requested_at | timestamptz | no | now() | |
completed_at | timestamptz | yes | set on COMPLETED / FAILED | |
attempt_count | integer | no | 0 | incremented on each failed attempt |
Erasure: the in-process job
There is no Temporal workflow and no cross-service deletion. Erasure runs as ErasureJob, a single goroutine in the consent process that ticks on a timer (ERASURE_POLL_SECONDS, default 60).
Each tick, the job loads requests in PENDING or PROCESSING (the latter to recover from a crash mid-execution), marks the row PROCESSING, then runs the erasure against consent's own tables only:
SOFT: setsreason = NULLon the party'sconsent_auditanddeletion_requestsrows (UPDATE). No other masking; the consent and audit rows themselves are kept.HARD:DELETEs the party's rows fromconsent_records,consent_audit, anddeletion_requestsin one transaction.
On success the row goes to COMPLETED and a deletion.completed event is published (for both SOFT and HARD). On failure attempt_count is incremented: below ERASURE_MAX_ATTEMPTS (default 3) the row is re-queued to PENDING; at the limit it goes to FAILED and a deletion.failed event is published. Retry is a fixed-interval re-queue; there is no exponential back-off and no 30-day SLA or deadline logic.
GET .../deletion/{id} returns the bare request row. There is no per-service step list (deletion_request_steps does not exist).
Planned, not implemented
- Cross-service erasure (fan-out to Claims, Care, Triage, etc. via per-service deletion endpoints) is not built. Other services retain the party's data after a consent-service erasure.
- A
HARD-delete confirmation header (e.g.X-Confirm-Hard-Delete) is not read or required; HARD requests are accepted on the deleteType check alone. - Regulatory-hold masking (retaining FCA / clinical records under SOFT) is a design goal but is not implemented beyond nulling
reason.
Events
The service produces on two distinct paths; it consumes nothing.
Outbox path: business writes enqueue in the same transaction as the DB change, and the outbox worker drains to topic consent.events, keyed by partyLocator. The worker wraps each row in the platform's canonical envelope (see the Kafka Event Catalog): eventId (the outbox row id, so re-publications dedupe), eventType, occurredAt, partyLocator, correlationId (the originating request's trace id), causationId, client lineage (sessionId / activityId / activityName lifted from W3C baggage), the payload exactly as the handler wrote it, and state, the subject entity frozen at emit time.
eventType | Emitted when | State subjects |
|---|---|---|
consent.changed | every consent upsert (first grant, re-grant, withdrawal); carries the new granted boolean | consent |
consent.granted | transition into granted (first grant or re-grant after withdrawal), layered on top of consent.changed in the same transaction | consent |
consent.withdrawn | transition out of granted (true to false), same transaction | consent |
consent.deletion.requested | a deletion request is accepted and persisted PENDING | deletionRequest |
Erasure-job path: the job publishes terminal outcomes through a direct Kafka producer, best-effort, not the outbox, each event to its own topic named after the type, keyed by partyLocator. Messages ride the same envelope shape minus correlationId / causationId / top-level partyLocator (the direct producer has no trace-context store); client lineage is lifted from baggage when the caller carries it (the ticker path has none), and state freezes the settled deletionRequest row, which matters because the erasure destroys the data the payload's locator points at.
Topic = eventType | Emitted when | State subjects |
|---|---|---|
deletion.completed | erasure succeeds (SOFT or HARD); request settles COMPLETED | deletionRequest |
deletion.failed | retries exhausted (ERASURE_MAX_ATTEMPTS); request settles FAILED | deletionRequest |
Per-type contracts (payload JSON Schema, state subjects, lineage requirements, producers/consumers as code refs, golden examples) live in the Event Registry under packages/go/domain/eventregistry/registry/<eventType>/.
Notifications wiring
Member-facing notifications go through Novu workflows triggered off the events above; the consent service never calls SMTP, SendGrid, FCM, or Twilio directly. The olly-notifications adapter (olly-notifications:4006) is the intended consumer that maps these events onto Novu workflows.
| Event | Suggested Novu workflow | When |
|---|---|---|
consent.events / consent.changed | consent-changed | Any consent grant or revoke |
consent.events / consent.deletion.requested | deletion-requested | Deletion request accepted |
deletion.completed | deletion-completed | Erasure finished (both SOFT and HARD) |
The completion notification is not HARD-only; the job fires deletion.completed for both delete types.
Dependencies
| Dependency | Purpose | Failure mode |
|---|---|---|
Postgres (dev-2) via DATABASE_URL | persistence + outbox | hard fail |
Keycloak (KEYCLOAK_JWKS_URI) | JWT validation; absent ⇒ routes open | hard fail when configured |
Kafka (KAFKA_BROKERS) | outbox drain + erasure-job events | degraded: outbox rows accumulate unpublished, no business data loss; erasure-job events are best-effort and may be dropped on outage |
| OTel collector | tracing (outbox publish joins the originating request trace) | degraded |
There is no Temporal dependency and no dependency on other Olly services. Novu delivery is downstream of the olly-notifications consumer and does not affect consent state.
Invariants
consent_recordsis upsert-only keyed on(party_locator, consent_type); every upsert writes aconsent_auditrow and aconsent.eventsoutbox row in the same transaction.PUT /consent/{partyLocator}is MEMBER-role only when JWT claims are present.- At most one active deletion request per party: a second
POSTwhile one isPENDING/PROCESSINGreturns409. - Deletion status moves
PENDING → PROCESSING → COMPLETED/FAILED, withPROCESSING → PENDINGre-queue on a failed attempt below the retry limit. The terminal states areCOMPLETEDandFAILED. - Erasure affects only consent's own three tables. SOFT nulls
reason; HARD deletes the rows.
Caveats
- Audit is not DB-enforced-immutable. There is no trigger, RLS rule, or
REVOKEonconsent_audit. The erasure job itself mutates it: SOFT runsUPDATE consent_audit SET reason = NULLand HARDDELETEs the rows. Treat audit as append-only by convention only, and note erasure is an explicit exception. - No Cloud SQL / GCP. The service runs as a container against dev-2 Postgres; any Cloud SQL / GKE framing is aspirational, not the current deployment.
changed_by_typeis alwaysMEMBER. The handler hard-codes it on the audit row regardless of the caller.
