Consent is not a boolean preference. Evidence must explain who acted, when, against which processing purpose and notice version, through which channel, and when that action was withdrawn. The current module preserves a basic history row but does not yet provide a governed purpose registry or complete receipt.
1. Current data model
Every RecordConsent call appends one SysConsent row. Withdrawal sets RevokedAt and preserves the row.
| Field | Current source | Meaning |
|---|---|---|
TenantId | Store Effective Tenant | final isolation boundary |
UserId | CurrentUser, falling back to "0" | subject identifier |
Purpose | request body | only checked for non-empty |
ConsentVersion | request body | only checked for non-empty |
GrantedAt | IAppClock.UtcNow | grant time |
RevokedAt | withdrawal command | null means active row |
IpAddress | not populated | column exists without command/store input |
UserAgent | not populated | column exists without command/store input |
IX_SysConsent_User_Purpose is a non-unique index. A user can have several active rows for the same purpose and version.
2. Grant path
The command does not implement IAuthorizedRequest. It relies on endpoint authentication and does not validate the purpose against a registry, confirm that the notice version is current, or detect an existing active receipt.
3. Withdrawal and idempotency
DELETE /api/privacy/consents/{id} loads the row under the Effective Tenant. The handler then allows the row owner or a caller with a role whose name equals admin. A repeated withdrawal returns success without changing the stored timestamp.
using var request = new HttpRequestMessage( HttpMethod.Delete, "/api/privacy/consents/consent-42");
// The bearer identity must own the row or have the role named admin.request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await http.SendAsync(request, cancellationToken);
// 204 represents both the first and a repeated withdrawal.response.EnsureSuccessStatusCode();In addition to the handler check, the authorization pipeline evaluates gdpr.own-data.delete. Possessing the catalog value privacy.consent.manage does not satisfy that decision.
4. What withdrawal does not do
The current write only changes RevokedAt. No source path:
- publishes withdrawal to marketing, analytics, or external processors;
- records processor acknowledgement or stop time;
- cancels future work based on that consent;
- calculates one effective consent per purpose;
- snapshots notice text, locale, jurisdiction, channel, or proof material.
A withdrawn database row is therefore not evidence that every processor stopped.
5. Computing effective consent
The query maps every row with RevokedAt is null to IsActive = true. It does not group by purpose or invalidate an obsolete notice version.
public static ConsentDecision Evaluate( ConsentPurposeDefinition purpose, IReadOnlyCollection<ConsentReceipt> history, DateTimeOffset now){ // Processing is unavailable when the governed purpose is inactive. if (!purpose.Enabled || purpose.ValidUntil <= now) return ConsentDecision.Denied("purpose-inactive");
// The newest event must grant the currently published notice. var latest = history .Where(x => x.PurposeCode == purpose.Code) .OrderByDescending(x => x.RecordedAt) .FirstOrDefault();
return latest is { Kind: ConsentEventKind.Granted } && latest.NoticeVersion == purpose.NoticeVersion ? ConsentDecision.Allowed(latest.ReceiptId) : ConsentDecision.Denied("no-current-consent");}These are target types, not current repository classes. The important separation is immutable history versus the current processing decision.
6. Production receipt
A governed receipt commonly needs:
| Dimension | Suggested fields |
|---|---|
| Subject | TenantId, SubjectId, identity assurance level |
| Purpose | PurposeCode, LawfulBasis, ProcessorScope |
| Notice | NoticeVersion, NoticeHash, Locale |
| Action | Granted/Revoked, OccurredAt, Channel |
| Evidence | CorrelationId, network/device summaries, ReceiptId |
| Lifecycle | SupersedesReceiptId, RetentionClass, LegalHold |
Network identifiers should not be stored indefinitely by default. Privacy owners must decide whether to keep IP data, how to minimize it, and how long it remains available.
7. Concurrency and uniqueness
The current grant path does not query before insert. If policy permits only one active grant for a purpose:
- retain immutable events;
- maintain a concurrency-controlled current projection;
- enforce active-row uniqueness in the database;
- require a stable idempotency key;
- return a stable conflict when another writer wins.
-- ActiveKey is 1 only for the current active projection.CREATE UNIQUE INDEX UX_Consent_ActivePurposeON SysConsent (TenantId, UserId, Purpose, ActiveKey)WHERE ActiveKey = 1;Adapt the design to the target database and both ORM adapters. This is not a ready migration.
8. Operate-as boundary
The store writes the Effective Tenant but receives UserId from CurrentUser. Reads intersect an actor-tenant argument with Effective Tenant. Operate-as can therefore write a target-tenant row for the actor identifier or return an empty list.
A production policy should choose one of two explicit paths:
- self-service consent rejects Host and operate-as callers;
- assisted recording uses a separate operator command with SubjectId, authority, and approval evidence.
Actor, subject, and effective tenant must be separate fields.
9. Test contract
Minimum coverage includes:
- repeated grants for the same purpose and version;
- repeated withdrawal preserving the first withdrawal time;
- owner, role-name admin, and actual decision-permission combinations;
- cross-tenant ID probing returning a uniform result;
- actor/subject/effective tenant separation during operate-as;
- redaction and retention if network evidence is enabled;
- atomic state and Outbox behavior when withdrawal propagation fails.
Current automation covers only the consent list handler’s store call and basic dual-provider persistence.
10. Release review
# Prove whether the network evidence columns are populated.rg -n "IpAddress|UserAgent" src/Platform/Gdpr -g '*.cs'
# Compare consent catalog values with the request resource.rg -n "ConsentManage|own-data|RevokeConsent" src/Platform/Gdpr -g '*.cs'
# Inspect whether an active-consent uniqueness constraint exists.rg -n "BitzIndex" src/Platform/Gdpr/BitzOrcas.Platform.Gdpr.Infrastructure/Persistence