Consent audit & data-subject rights
Schema deep-dive · living document · #17 in the reading sequence
| Tables | consent.consent_audit[1], consent.deletion_requests[2] (the compliance half of the consent DB; the current-state sibling consent_records is on the previous page) |
| Owner service | consent (:4012) - sole writer of both tables |
| Locator | natural key - consent_audit has no locator (keyed by party_locator + consent_type + occurred_at); deletion_requests is keyed by its id uuid, returned to the requester for polling. No XXX-YYYY-NNNNNN locator on either |
| Last updated | 2026-08-21 |
| Companion | Consent & GDPR (narrative) · Party · ERD story, slide 12 · previous: Consent, documents & notifications (#12) |
1. Scope and usage
These two tables are the compliance spine of the consent service. Where consent_records answers "what does the member allow right now?", these answer "what changed, who changed it, and does the member want out?" - the UK-GDPR / Data Protection Act 2018 machinery behind the right to an accurate audit trail (Art. 5(2) accountability) and the right to erasure (Art. 17). IF2's own framing of the latter is "the right to erasure (i.e. request that their personal data be deleted and not disseminated further)"
consent_audit is an append-only ledger: one row for every consent change, written in the same transaction as the record upsert - old value, new value, who, as what, why, when. It is the platform's reference append-only audit primitive, and it records what reconstructing a consent history needs: per-change attribution, before-and-after values, a reason and a timestamp, for any consent_type the product introduces later without a migration (§9). At 2,218 live rows as of 2026-08-21 it is the most-populated table in this documentation batch, though 2,154 of those rows are atomicity-harness residue (changed_by = atomicity@test.olly, all NHS_DATA_SHARING) and the ledger holds exactly one withdrawal, a single true→false transition. The mechanism is correct; the population is a data-hygiene finding (§9).
deletion_requests is the UK-GDPR Art-17 erasure queue: a member (or an employer on a scheme member's behalf) asks for SOFT or HARD erasure, a background poller executes it and publishes a terminal event. The queue is complete end to end - intake, poller, bounded retry, on-demand admin tick, terminal Kafka event - and its operating record is e2e rather than live: all 31 rows are e2e synthetic (PTY-E2E-*), created inside one 3.5-hour window on 2026-08-12, all SOFT, with the 5 FAILED rows deliberate fault-injection sentinels. No data-subject request has reached it at this stage of the product. That is sequencing.
What the erasure does is a separate matter, and it is narrower than the name: a COMPLETED SOFT erasure NULLs the free-text reason in consent's own two tables and nothing more; HARD deletes only consent-local rows; and the cross-service fan-out that would reach the member's data in policy_admin.parties and every core service is a Kafka signal no service consumes. Measured against Art-17 that is an erasure signalled, not satisfied. Stated in full, as the fix and where, in §9.
2. Boundaries and relationships
| These tables are not… | That concern lives in | Join |
|---|---|---|
| the current consent state - a flag you can read in a request path; the audit ledger is history, written alongside the state, never read to make a decision | consent.consent_records (previous page)[5] | same (party_locator, consent_type) |
| an authorization gate - a recorded change is a fact, not a permission; nothing in any request path consults the audit trail before serving data | the authz track (PII walls) | - |
| the PII store - the audit row knows a consent flipped, not the member's health data; an erasure request lands here but the member's actual personal data lives elsewhere | Party (policy_admin.parties) and every core service | party_locator |
cross-service erasure execution - SoftDeleteParty / HardDeleteParty touch only the consent DB; the estate-wide fan-out is signalled on Kafka (deletion.completed / deletion.failed) and consumed by no one[23] | the (unbuilt) erasure orchestrator | topic only |
| a general platform audit log - this is consent-scoped; a single cross-domain audit is aspirational, not built | - | - |
The shared join is party_locator - always a soft text reference, never an FK (cross-service rule, data architecture). Nothing validates the format on write: consent_audit carries 2,154 PARTY-ATOM-* strings and 61 rows with an empty changed_by alongside real PTY- locators, and every deletion_requests row is a PTY-E2E-* string (§9).
3. Structure
consent_audit
Append-only history. No UPDATE or DELETE path exists in the repository layer; erasure is the one exception, and it only NULLs reason (SOFT) or removes the row entirely (HARD) - both documented in §5.
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK; minted by the model's BeforeCreate hook[3] |
party_locator | text | ✓ | The consenting subject. Denormalised - the audit row survives even if the record is hard-deleted. Indexed |
consent_type | text | ✓ | Denormalised copy of the record's type; no CHECK, no FK, so a new consent purpose is data rather than a migration - the vocabulary lives in one application map[26] (§9) |
old_value | boolean | NULL on the first-ever change (nothing to change from); captured from the prior record[4] | |
new_value | boolean | ✓ | The value after the change |
changed_by | text | ✓ | Free text: a PTY- locator (member self-service), slack-bot, an email (atomicity@test.olly), or empty |
changed_by_type | text | ✓ | MEMBER | EMPLOYER | SERVICE (application enum only)[9] |
reason | text | Free text; the one field SOFT erasure redacts to NULL[20] | |
occurred_at | timestamptz | ✓ | DEFAULT now(); indexed. The read API orders by it ASC[11] |
There is no UNIQUE on consent_audit - by design, a party accrues many rows per type over time. Only two indexes exist (party_locator, occurred_at)[1].
deletion_requests
The Art-17 erasure queue. One row per request; the id is returned to the caller for polling.
| Field | Type | Req | Notes |
|---|---|---|---|
id | uuid | ✓ | PK; returned by intake and used for the poll/process routes |
party_locator | text | ✓ | The subject of the erasure. Indexed |
delete_type | text | ✓ | SOFT | HARD (application check)[12]. All 31 live rows are SOFT |
status | text | ✓ | DEFAULT 'PENDING'; walk is PENDING → PROCESSING → COMPLETED / FAILED (§5). Indexed |
requested_by_type | text | ✓ | MEMBER | EMPLOYER (application check)[12] |
reason | text | Free text; SOFT erasure NULLs this on the party's own request rows[20] | |
requested_at | timestamptz | ✓ | DEFAULT now() |
completed_at | timestamptz | Stamped on terminal states (COMPLETED and FAILED both)[16] | |
attempt_count | integer | ✓ | DEFAULT 0; the erasure job's bounded-retry counter |
No UNIQUE here either - "one active request per party" is an application 409 check[13], racy by design (§4).
Field-by-field: what and why
consent_audit as double-entry with the record - the reason this table exists in the shape it does. Every consent change runs recordConsentChange, which first reads the prior value (for old_value), then in one transaction upserts the consent_records row and inserts the audit row and enqueues the outbox event[4]. The record-plus-audit half is UpsertWithAudit, whose inner GORM Transaction nests as a SAVEPOINT inside the handler's outer tx so all three writes commit or none do[5]. State, history and the cross-service announcement can never drift apart - the platform's reference append-only audit primitive, and the point the sibling page calls "the best organic chain in the estate".
old_value and the semantic-event fork - old_value is nil on a first-time grant, and the handler uses that same prior value to decide which semantic event to layer on top of the generic consent.changed: a nil/false → true transition emits consent.granted, a true → false transition emits consent.withdrawn[4] (#1644). So the audit row's old_value is both the durable record and the switch that shapes the event stream. In the model, OldValue is a *bool precisely so "no prior value" and "prior value was false" stay distinguishable[3].
changed_by / changed_by_type - the attribution pair, and where the two write paths differ. The public PUT /consent/{party} requires the member role (or a bare party_locator claim, for D2C onboarding without the role - #1675) and hardcodes changed_by_type = MEMBER, overriding the path param with the JWT's verified party_locator so a member can only write their own record (#1544)[6]. The cluster-only PUT /internal/consent/{party} path (X-Internal-Service secret, constant-time compare, fail-closed on empty secret) instead requires an explicit changed_by_type[7][8] - this is how the Slack bot records SLACK_CHAT_PERSONALIZATION as SERVICE on a member's behalf[30]. In the live table that split is stark: 2,217 MEMBER rows to 1 SERVICE row (§6).
reason - free text, and the only thing SOFT erasure touches. It carries whatever the caller supplied ("member replied YES in Slack DM"). Because the Go field is a non-pointer string, the write path always stores '' for an omitted reason, never NULL - so a NULL reason is the signature of SOFT erasure having run[20] (§6 shows this holding on deletion_requests, and §9 shows why it is muddied on consent_audit).
attempt_count, status and the erasure job - the poller (every 60s by default[25]) picks up PENDING and PROCESSING rows (crash recovery), marks PROCESSING, executes, and on a transient failure increments attempt_count, retrying until it hits max 3 - at which point it FAILs the request, stamps completed_at and publishes deletion.failed[16]. What execution actually does is narrower than "erasure" suggests: SOFT NULLs the reason columns in consent's own two tables and nothing else; HARD deletes the party's consent_records, consent_audit and deletion_requests rows - all within the consent database[20][21]. The estate-wide erasure is signalled on Kafka but never performed (§2, §9).
4. Invariants
| Invariant | Enforced by |
|---|---|
| Every consent change leaves an audit row | Application transaction: recordConsentChange upserts the record + inserts the audit row + enqueues the outbox event in one tx[4][5] |
| Audit is append-only | Convention + code shape - no UPDATE/DELETE method on the audit repo (AuditRepository exposes only ListByParty)[31]; not a DB rule. A GRANT or trigger would harden it. Erasure is the sanctioned exception (reason-NULL / row-delete) |
old_value distinguishes "no prior" from "was false" | Application: *bool, set only when a prior record is found[4] |
changed_by_type, delete_type, requested_by_type vocabularies | Application only - no CHECK anywhere in this ring[9][12] |
| Public consent writes are always attributed to the member | Application: changed_by_type hardcoded MEMBER, path param overridden by JWT party_locator (#1544)[6] |
| A member reads only their own audit trail / erasure request | Application: JWT party_locator claim overrides the path on the audit read[10]; the deletion poll 404s (not 403) on a foreign party[15] |
| One active deletion request per party | Application 409 check (GetActiveByParty), racy by design - no partial unique index[13] |
| Erasure retries are bounded | Application: attempt_count vs max 3 (default), then FAILED + deletion.failed[16] |
| An on-demand tick cannot re-fire a settled request | Application: ProcessRequestByID no-ops on any non-PENDING/PROCESSING status[19] |
| Only admin/service roles may run erasure on demand | Application: hasAdminRole against defaultConsentAdminRoles (admin, mcp:operator; employer-admin deliberately absent)[14][28] |
party_locator points at a real party | Nothing - live data is dominated by test-fabricated locators (§9) |
| Row changes captured to CDC | Debezium publication dbz_consent (covers both tables; live \d) |
5. Lifecycle
consent_audit has no lifecycle: a row is born terminal, immutable from insert - the only mutations are erasure (reason→NULL, or row deletion). The machine that matters here is deletion_requests.
Every arrow is grounded in code. Intake is postDeletion: it validates the SOFT/HARD and MEMBER/EMPLOYER enums, 409s if an active request already exists, then inserts the row and enqueues consent.deletion.requested in one tx[12]. The poller's ProcessOnce lists PENDING and PROCESSING rows - so a request orphaned mid-execution by a crash is retried, not stranded[17][22]. processRequest marks PROCESSING, runs SoftDeleteParty / HardDeleteParty, and on success stamps COMPLETED + completed_at and publishes deletion.completed; on a transient error it increments attempt_count and drops back to PENDING, or FAILs at the cap[16].
The only FAILED path that fires in practice is a test sentinel. The deployed Soft/HardDeleteParty queries are UPDATE/DELETE … WHERE party_locator = ? - a no-op on zero rows, so they succeed for any party and no organic HTTP request can induce the delete error the retry loop needs. To give deletion.failed a reachable trigger for e2e, a party locator prefixed PTY-E2E-FAULT- is forced to a synthetic, terminal failure at attempt 1 (bypassing the max-3 loop)[18]. That is why all 5 live FAILED rows sit at attempt_count = 1 (§6). An admin (realm admin / mcp:operator) can also drive a single request synchronously via POST /consent/deletion/{id}/process, running the same pipeline scoped to one id[14].
6. Populated examples
Values pulled live from dev-2 postgres 2026-08-21; locators/uuids real, free-text reason redacted.
A consent_audit row - the one organic SERVICE entry
The Slack bot recording chat-personalisation consent after the member replied YES in a DM (the /internal/consent SERVICE path, §3) - the single non-MEMBER row in 2,218:
{
"party_locator": "PTY-2026-000121",
"consent_type": "SLACK_CHAT_PERSONALIZATION",
"old_value": null,
"new_value": true,
"changed_by": "slack-bot",
"changed_by_type": "SERVICE",
"reason": "[redacted free text]",
"occurred_at": "2026-07-21T13:37:56Z"
}| Field | Read by | What actually happens |
|---|---|---|
old_value: null | the semantic-event fork + the audit API | "first-ever grant" - there was no prior state, so the handler also emits consent.granted[4] |
changed_by_type: SERVICE | audit readers | distinguishes bot-recorded consent from member self-service (which hardcodes MEMBER) |
changed_by: slack-bot | audit readers | the internal path let a trusted service name itself; the public path could not |
occurred_at | GetAudit ORDER BY | the trail renders oldest-first[11] |
The estate's only withdrawal lives on the seeded demo member PTY-2026-000004: a DATA_SHARING row with old_value: true → new_value: false (its sibling row is a MARKETING first-grant). Both are legacy vocabulary the current validator still accepts specifically so a member can withdraw a consent they can no longer be freshly granted[26]. Every other one of the 2,218 rows is a grant (new_value: true, old_value: null) - 2,154 of them the atomicity@test.ollyNHS_DATA_SHARING harness.
Live distribution (2026-08-21): by consent_type - NHS_DATA_SHARING 2,154, TERMS_OF_SERVICE 32, PRIVACY_POLICY 29 (the FR-37 versioned document-acceptance rows), DATA_SHARING / MARKETING / SLACK_CHAT_PERSONALIZATION 1 each. By new_value - 2,217 true, 1 false. By changed_by_type - 2,217 MEMBER, 1 SERVICE. 2,187 distinct parties.
A deletion_requests row - a COMPLETED SOFT erasure
The representative row is one of 26 e2e SOFT completions:
{
"id": "112e21f7-0930-43ab-8fc4-391947872c50",
"party_locator": "PTY-E2E-DEL-1786543427042126942",
"delete_type": "SOFT",
"status": "COMPLETED",
"requested_by_type": "MEMBER",
"reason": null,
"requested_at": "2026-08-12T14:03:47Z",
"completed_at": "2026-08-12T14:04:06Z",
"attempt_count": 0
}| Field | Read by | What actually happens |
|---|---|---|
reason: null | (nobody at read time) | it was not requested null - SoftDeleteParty NULLed this request's own reason when the erasure ran for PTY-E2E-DEL-…. On COMPLETED SOFT rows the NULL is the erasure's own fingerprint[20] |
status: COMPLETED + completed_at | the requester's poll (GET …/deletion/{id}) | terminal; a second tick no-ops[19] |
attempt_count: 0 | (retry loop) | SOFT succeeded first try - the loop never engaged |
And a FAILED row - a deliberate fault-injection sentinel:
{
"id": "a6172a9e-e676-481f-8e4f-af4b4ad72770",
"party_locator": "PTY-E2E-FAULT-1786553696079751343",
"delete_type": "SOFT",
"status": "FAILED",
"requested_by_type": "EMPLOYER",
"reason": "[redacted free text]",
"completed_at": "2026-08-12T16:54:56Z",
"attempt_count": 1
}Note reason survives on the FAILED rows: the fault-inject path short-circuits to a synthetic error before running SoftDeleteParty, so it never redacts[18]. reason NULL vs not-NULL cleanly separates the 26 COMPLETED (redacted) from the 5 FAILED (retained).
Live distribution (2026-08-21, all 31 rows): by status - 26 COMPLETED, 5 FAILED, 0 PENDING/PROCESSING. By delete_type - 31 SOFT, 0 HARD. By requested_by_type - 19 MEMBER, 12 EMPLOYER. By attempt_count - 26 at 0, 5 at 1. Every party_locator is PTY-E2E-*, all created in a 3.5-hour window on 2026-08-12: no data-subject request has reached the queue at this stage, so the HARD path - the only one that deletes rows - has not yet run outside e2e. What HARD deletes when it does run is consent-local only (§9).
7. Who references these tables
Both tables are fan-in (they consume everyone's party_locator); the fan-out is thin.
| Where | Column / mechanism | Meaning there |
|---|---|---|
| member self-service | GET /consent/{party}/audit (JWT-scoped to own party)[10] | "my consent history" - and the audit read is scoped because changed_by can leak an email |
| dataloader / BI ingest | registry route /consent/:partyLocator/audit, id field | the audit trail as a loadable list surface |
| the requester's poll | GET /consent/{party}/deletion/{id} (uuid; 404 on foreign party)[15] | "is my erasure done?" |
| admin / mcp-operator | POST /consent/deletion/{id}/process[14] | run one erasure on demand without waiting for the poll |
| the erasure job | ListPending (PENDING + PROCESSING)[22] | the background poller's work queue |
consent.events topic | consent.deletion.requested via the transactional outbox[12][29] | request lifecycle start; no consumer today |
deletion.completed / deletion.failed topics | published directly (non-outbox) by the erasure job[23] | future cross-service erasure fan-out; no consumer today |
Debezium → BigQuery olly_cdc | publication dbz_consent | analytics / compliance lineage |
No other service's schema references these tables by FK or locator - by design, the compliance ring points outward, nothing points in. Every member-facing route above sits behind the JWT middleware group; the X-Internal-Service and admin-only tick routes are mounted separately[27].
8. Design determinations
- Every consent change is transactionally audited - record + audit + outbox in one tx; the platform's reference append-only audit primitive. (§3, §4)[4]
- The audit row is denormalised on purpose -
party_locatorandconsent_typeare copied onto every audit row so the history survives a HARD delete of the record. The same choice is what lets the ledger absorb a consent purpose the product has not introduced yet:consent_typeis unconstrained text, so a new purpose is one entry in the application vocabulary, not a migration or a backfill. (§3, §9)[26] - Two write paths, two attribution rules - the public path hardcodes
MEMBERand force-scopes to the JWT party; the internal path demands an explicitchanged_by_typefor trusted services (the Slack bot). #1544, #1525, #1675[6][7]. - Erasure is polled, attributed and bounded - PENDING/PROCESSING crash-recovery,
attempt_countcapped at 3, terminal events published; plus an admin-only on-demand tick for operators and e2e. (§5)[16] - SOFT vs HARD is a deliberate two-tier erasure - SOFT redacts free-text
reasonand retains the audit trail (regulated-retention posture); HARD removes the consent-local rows. Both are consent-DB-scoped by design, with the cross-service fan-out deferred to a Kafka signal. (§2, §9)[20][21] - Erasure-on-demand excludes
employer-admin- every employer holds it, so listing it would make erasure-on-demand employer god-mode; the allowed set isadmin+mcp:operator.[28] - The consent record shape is deliberately thin - D-03 (#1006, open); the audit row inherits that thinness (no lawful basis, no policy-text pointer beyond FR-37's
document_versionon the record). See the previous page.
9. Caveats and extensibility
Group and individual. Both tables key on party_locator and an employer is just an ORGANISATION party, so the design serves scheme-based and direct-to-consumer cover with no schema change. deletion_requests already admits requested_by_type = EMPLOYER (12 live rows) for erasure-on-behalf of a scheme member, alongside MEMBER self-service; a D2C individual is a MEMBER requester who is a tenant of one. The audit trail is identical either way - the group-ness lives in who is allowed to write, resolved in authorization, not in these columns.
Where to extend. The compliance ring was modelled ahead of the products and the request volume that use it, so most of the growth below is configuration or a consumer rather than a migration on these two tables.
| When we need … | What to add | Where |
|---|---|---|
| Real data-subject erasure requests to start arriving | Nothing in the queue itself: intake (SOFT/HARD, MEMBER/EMPLOYER, one-active-request 409), the 60s poller with PENDING+PROCESSING crash recovery, bounded retry at max 3, the admin on-demand tick and the terminal-event publish are built and exercised. Operating it is tuning ERASURE_POLL_SECONDS / ERASURE_MAX_ATTEMPTS and alerting on PENDING age. The fan-out row below is the part that is missing | internal/handler/deletion.go[12][14] · internal/job/erasure.go[16][17] · internal/config/config.go[25] |
| Erasure to span services - the Art-17 requirement, and the open gap below | A deletion.completed / deletion.failed consumer in every PII-holding service (policy-admin, claims, billing, care, document-service, triage, member-portal-api). The producer side needs no change: both events already carry partyLocator, deleteType/attemptCount and a self-describing eventType, keyed by partyLocator so a consumer partitions per subject | consumer per service; producer internal/kafka/producer.go[23] |
| A consent history reconstructible for an accountability audit | Nothing on the consent-change side: consent_audit is already the append-only per-change ledger - old value → new value, actor, actor type, reason, timestamp, one row per change written in the record's own transaction - and it holds any new consent type without a migration. Evidencing an erasure additionally depends on the fan-out row above, since today the ledger can only attest to what consent did | migrations/0003_create_consent_audit.sql[1], read via GET /consent/{party}/audit[10] |
| A new consent purpose (a new product consent, a new lawful basis to capture) | One entry in the validConsentTypes map. consent_type is unconstrained text on both consent_records and consent_audit, so the new purpose is data: no migration, no backfill, and the existing ledger and erasure paths cover it on day one | internal/handler/consent.go validConsentTypes[26] |
| The terminal erasure signals to survive a broker outage | Route deletion.completed / deletion.failed through the transactional outbox that already carries consent.deletion.requested, replacing the direct publish | publish calls in internal/job/erasure.go[16] · migrations/0005_create_outbox.sql[29] · the follow-up is recorded in cmd/main.go[24] |
| Append-only to be a database guarantee, not a repository convention | A BEFORE UPDATE/DELETE trigger or a revoked table grant on consent_audit, with a carve-out for the two sanctioned erasure paths | new migration on consent.consent_audit; the repo surface that holds the line today is internal/repository/repository.go[31] |
| The audit row to carry lawful basis, policy version or capture provenance | Additive nullable columns on consent_audit; the existing rows stay valid. FR-37 already proved the pattern with document_version on the record side | new migration on consent.consent_audit; shape blocked only on D-03 (#1006) |
| A partial unique guarantee instead of the 409 read | A partial unique index on (party_locator) where status IN ('PENDING','PROCESSING'), replacing the application check | new migration on consent.deletion_requests; check at internal/handler/deletion.go[13] |
Open gaps in the Art-17 capability. These are coverage gaps in a regulated capability, not artefacts of product stage, and they stay open until the fix lands.
- Erasure executes only inside the consent database.
SOFTNULLs the free-textreasoncolumn onconsent.consent_auditandconsent.deletion_requestsand touches nothing else, not evenconsent_records[20];HARDdeletes the party'sconsent_records,consent_auditanddeletion_requestsrows and stops there[21]. The member's personal data inpolicy_admin.partiesand every core service survives both. Fix: the fan-out consumers in the table above. Until they exist, an Art-17 erasure is signalled, not satisfied. - The cross-service fan-out is fire-and-forget, outside the outbox, and consumed by no service.
deletion.completed/deletion.failedare published directly to Kafka from the poller[23], the pattern the outbox was introduced to eliminate; a broker outage at completion time loses the signal, leaving a COMPLETED row whose fan-out no one will replay. Fix, two parts: route both events through the outbox, and add the per-service consumers. Both rows are in the table above; themain.gowiring comment records the first as the standing follow-up[24]. - The rendered narrative overstates what runs. Consent & GDPR describes
SOFTas "PHI is anonymised" andHARDas "purged / tombstoned". Neither matches the two queries above. Fix: correct that page to the consent-DB scope, or close the fan-out gap first and leave the text; do not cite it as evidence of Art-17 coverage in the meantime. consent_auditis ~97% test residue. 2,154 of 2,218 rows areatomicity@test.ollyNHS_DATA_SHARINGharness rows written with no teardown, and a further 61 carry an emptychanged_by. Strip the harness and the organic ledger is the FR-37 document-acceptance rows plus a handful of member and service changes, including the single withdrawal. This is a data-hygiene finding, not a schema one. Fix: a teardown step in the atomicity harness, plus a one-off delete of thePARTY-ATOM-*locators.
Smaller warts, stated:
- Append-only is convention, not a grant. Nothing in the DB forbids an UPDATE or DELETE on
consent_audit; the guarantee is only that the repo exposes no such method[31]. Anyone with psql can rewrite history. Fix in the table above. reasonNULL is a muddy erasure signal onconsent_audit. A NULLreasonshould mean SOFT erasure ran, since the Go write path always stores''. But the 3 live NULL-reason rows belong to un-erased seed parties (PTY-2026-000004, onePARTY-ATOM-*) - seeded directly with NULL- so on this table the signal is contaminated. It holds cleanly only on
deletion_requests(§6).
- so on this table the signal is contaminated. It holds cleanly only on
- "One active request per party" is racy. It is an application 409 read, not a partial unique index[13]; two concurrent requests can both pass the check and insert. Harmless today (the poller processes both, the second no-ops), but not a guarantee. Fix in the table above.
deletion_requests.completed_atis set on FAILED too. The column name reads as success; the code stamps it on both terminal states[16]. Read it as "settled_at", not "succeeded_at".
On the operating record. The queue's 31 rows are all e2e synthetic (PTY-E2E-*), all SOFT, from one 3.5-hour window on 2026-08-12, with the 5 FAILED rows fault-injection sentinels (PTY-E2E-FAULT-) terminal at attempt 1[18]. No live row sits at attempt_count 2 or 3, because the deployed delete queries are UPDATE/DELETE … WHERE party_locator = ? and succeed against any party, so the retry-to-cap path has no organic failure to catch (§5) - the sentinel exists precisely to give it a reachable trigger. Read this as sequencing and test design, not as a statement about the gaps above.
References
Code links are pinned to commit b61c5802 on main (2026-08-21); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.
consent/migrations/0003_create_consent_audit.sql- consent_audit DDL + the two indexes (party L13, occurred L14); no UNIQUEconsent/migrations/0004_create_deletion_requests.sql- deletion_requests DDL + party/status indexes;status DEFAULT 'PENDING',attempt_count DEFAULT 0packages/go/domain/consent.go#L29-ConsentAuditEntry(OldValue*boolL33) +DeletionRequestmodels; idBeforeCreatehooksconsent/internal/handler/consent.go#L120-recordConsentChange: prior-value capture L126-134, upsert+audit+outbox in one tx L153-166, semantic granted/withdrawn fork L168-195consent/internal/repository/gorm_consent.go#L25-UpsertWithAudit: ON CONFLICT record upsert + audit insert, inner tx nests as SAVEPOINTconsent/internal/handler/consent.go#L63-updateConsentpublic path: member/party gate L78-88 (#1544, #1675), hardcoded MEMBER attribution L107consent/internal/handler/internal_consent.go#L36-internalServiceGuard: constant-time compare, fail-closed (503) on empty secretconsent/internal/handler/internal_consent.go#L77-updateConsentInternal: explicitchangedByTyperequired, SERVICE-attributed writesconsent/internal/handler/internal_consent.go#L15-validChangedByTypes(MEMBER/EMPLOYER/SERVICE) - application enum, no DB CHECKconsent/internal/handler/audit.go-getAudit: member-scoped read (#1544, changedBy-email-leak comment L11-15),reason,omitemptyconsent/internal/repository/gorm_audit.go#L16-ListByPartyorderedoccurred_at ASCconsent/internal/handler/deletion.go#L18-postDeletion: SOFT/HARD + MEMBER/EMPLOYER validation L36-43, insert +consent.deletion.requestedoutbox in one tx L64-79consent/internal/handler/deletion.go#L45- one-active-request 409 check (GetActiveByParty), racy by designconsent/internal/handler/deletion.go#L93-processDeletion: admin-role gate L94-98, on-demand single-request tick, post-tick state read-backconsent/internal/handler/deletion.go#L146-getDeletion: uuid poll, 404-not-403 on foreign party (#1544)consent/internal/job/erasure.go#L138-processRequest: PROCESSING → execute → COMPLETED, or bounded-retry (max default 3 L175-178) → PENDING/FAILED,completed_aton both terminals L179-191consent/internal/job/erasure.go#L72-Run60s poll loop +ProcessOnceover the pending listconsent/internal/job/erasure.go#L15-FaultInjectPartyPrefixsentinel + rationale (PTY-E2E-FAULT-, terminal fault at attempt 1) L138-158consent/internal/job/erasure.go#L115-ProcessRequestByID: terminal no-op guard so a double-tick cannot re-emit completion/failureconsent/internal/repository/gorm_deletion.go#L70-SoftDeleteParty: raw-SQLreason = NULLon consent_audit + deletion_requests onlyconsent/internal/repository/gorm_deletion.go#L80-HardDeleteParty: delete consent_records + consent_audit + deletion_requests for the party, one txconsent/internal/repository/gorm_deletion.go#L36-GetActiveByParty(PENDING/PROCESSING) +ListPending(includes PROCESSING for crash recovery)consent/internal/kafka/producer.go#L63- direct (non-outbox)deletion.requested/deletion.completed/deletion.failedpublishersconsent/cmd/main.go#L63- erasure job wiring +Run; comment L65-68 records the direct-producer-is-best-effort follow-upconsent/internal/config/config.go#L31-ERASURE_MAX_ATTEMPTSdefault 3,ERASURE_POLL_SECONDSdefault 60consent/internal/handler/consent.go#L16-validConsentTypesincl. FR-37 Privacy/Terms + legacy vocabulary kept accept-only (#1640 B5) so legacy grants stay withdrawableconsent/internal/handler/handler.go#L97- route table (audit, deletion intake/poll/process) inside the JWT group; internal routes mounted separatelyconsent/internal/handler/claims.go#L36-defaultConsentAdminRoles= admin, mcp:operator; employer-admin deliberately absentconsent/migrations/0005_create_outbox.sql#L10- the transactional outbox behindconsent.deletion.requested(created schema-unqualified →public)identity/internal/handler/slack_bot.go#L585-putConsentGrant: the SERVICE-attributed/internal/consentcaller (Slack DM personalisation)consent/internal/repository/repository.go#L26-AuditRepository(read-only: onlyListByParty) +DeletionRepositorysurface
Live-schema facts (Debezium publication dbz_consent on both tables; the consent_type / new_value / changed_by_type / changed_by distributions; the single withdrawal; the deletion_requests status / delete_type / requested_by_type / attempt_count distributions; the reason-NULL split; the worked example rows) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d consent · \d consent.consent_audit, \d consent.deletion_requests, select … group by …, 2026-08-21.
