The consent checkbox on a regulated intake form is usually a lie. The page shows the member the right words. The database does not record what they agreed to in a way that survives the next policy change. I have seen this at three separate regulated products in the last two years, at different maturity levels, all with the same underlying shape. The fix is the same in each case: treat consent as an immutable event, not a UI state.
This post catalogs the three instances and the common pattern. By the end you will have a model for consent capture that handles first-time intake, re-entry, renewal, and revocation without reconstruction work during an audit.
The pattern: consent as an event, not UI state
Most applications that capture consent model it as a boolean on the member profile. marketing_opt_in: true. hipaa_acknowledged: true. The boolean might carry a timestamp and maybe a policy version, but the moment a member updates something on their profile, or the policy changes, the old state is lost. The boolean is always "current." The history is gone.
The event-sourced pattern is different. Every consent interaction writes a new row to an append-only table. The row captures who consented, what the exact text was, which policy version was in force, and the context (IP, user agent, application build). The current "state" of consent is derived by querying the latest applicable event, but every past event is still queryable.
This is the same model behind the audit-logging pattern in the cluster, with a specific schema adapted for consent. The append-only property comes from Postgres role permissions exactly as described in the regulated audit-log patterns post. A consent row written once cannot be updated or deleted.
Instance 1: first-time intake with a versioned policy
The first time I watched this pattern break was on a regulated DTC intake flow where the legal team was iterating on the privacy policy every few weeks. The marketing team kept shipping new intake form versions to test conversion. Every few iterations, the legal team would ask: "for the 400 members who signed up in March, which version of the privacy policy did they actually agree to?"
The answer was always an hour of reconstruction work. Git blame on the intake form component. Correlate timestamps on the member records. Cross-reference policy versions stored in a CMS. The reconstruction was never clean; it was a best guess based on deployment timestamps, which is not a compliance-grade answer.
The resolution pattern is to record consent with the exact text, not a reference to it.
create table consents.events (
id bigserial primary key,
member_id uuid not null,
consent_type text not null,
policy_version text not null,
policy_text_sha text not null,
accepted boolean not null,
accepted_at timestamptz not null default now(),
ip_address inet,
user_agent text,
request_id uuid not null
);
The policy_text_sha is the SHA256 of the exact text shown to the member at the moment of consent. The policy text itself lives in a separate immutable store keyed on the hash. When the legal team asks "what did this member agree to on March 12," you pull the consent row, look up the policy text by hash, and have the exact words the member saw.
The application also needs to know which policy versions are current. A small consents.policies table keyed on (consent_type, policy_version) stores the active version per consent category. The intake page renders the current version. The consent event records the version and the hash together, so even if the current version later changes, the historical event stays intact.
Instance 2: re-entry and renewal without orphan consents
The second instance shows up in any product where a member returns to the flow. A regulated telehealth product I worked with had members completing intake, getting an initial fulfillment, and then returning weeks later to update their information or request a new service. The original intake form had long since been replaced. The privacy policy had been updated twice.
The failure mode: the product team modeled re-entry as an update to the existing member record. The consent boolean was already true, so no new consent was captured. Months later, the compliance team asked what the member had agreed to at the point of the renewal. The answer was still the original consent, under a policy version that was no longer current. The member was effectively operating under a consent artifact that the current product was not serving.
The event-sourced pattern handles this by design. A re-entry is a new consent event with consent_type = 'renewal' (or the appropriate sub-type) referencing the current policy version. The original intake consent is still in the table. The renewal consent is a new row. A query for "what is the member's current consent" returns the latest row per consent type.
This solves a second problem: consent revocation. A member can revoke a specific consent (say, marketing communications) without invalidating their core intake consent. The revocation is another event with accepted = false. Current-state queries use the latest event per type, so the revocation takes effect immediately for any downstream system reading from the consent table. The same pre-merge BAA checklist I keep on hand applies here: every vendor that receives consent-dependent data needs to be reading the current state, not a cached copy.
Instance 3: revocation that actually stops the data flow
The third instance is where consent capture meets the rest of the architecture. A DTC healthcare brand built consent capture correctly at intake but had no mechanism to stop downstream data flows when consent was revoked. A member who revoked marketing consent still received emails for weeks because the email list was a cached snapshot synced to a third-party provider. The consent event fired. The downstream sync did not.
The resolution pattern has two halves. First, the consent event is the canonical source of truth, and every downstream system queries it on read (or subscribes to consent-change events) rather than caching a snapshot. Second, the revocation path triggers cleanup actions: remove from marketing segments, unsubscribe from vendor systems, purge cached state where relevant.
In practice, this looks like a small service that watches the consent events table and fans out to the downstream cleanup actions. For a small team, it can be a server action triggered on the consent write; for larger teams, a background worker reading from a change feed. The key property is that revocation has code, not just a UI toggle.
Teams that want the tracking-layer side of this checked against a structured framework usually start with the stack audit I run for DTC brands, which flags consent-to-pixel timing gaps that compound with regulated consent errors.
The hub article on the regulated DTC healthcare architecture goes deeper on the commerce-clinical boundary and why this cleanup is harder than it looks when the two systems are loosely coupled. The short version: if the commerce side holds derivative entitlement data and the clinical side holds truth, revocation needs to propagate across the boundary in a way that is atomic enough to satisfy an auditor.
“The consent event is the canonical source of truth, and every downstream system queries it on read rather than caching a snapshot.”
What the pattern tells us
All three instances point at the same underlying shape. Consent is a legal event with compliance implications. A UI checkbox is the capture mechanism, but the durable record is what survives audit. Teams that model consent as mutable state on a profile end up reconstructing history under pressure and usually cannot do it cleanly. Teams that model consent as an append-only event stream answer the audit questions in minutes.
The pattern also illuminates why consent is often miscategorized as a front-end concern. The front end renders the right text. The back end records the right event. The front end's job is ergonomic; the back end's job is evidentiary. Confusing the two is where the bugs live.
How to spot the gap early
Four questions tell you whether a consent implementation is event-sourced or state-based.
Pick a specific member. Can you show, with a single query, every consent-related action they have taken in the last year? If the answer involves joining across table versions or running migrations to reconstruct past state, the implementation is state-based.
If the privacy policy was edited last Tuesday, can you tell which members consented before the edit and which consented after? An event-sourced implementation answers this instantly by filtering on accepted_at. A state-based implementation needs git archaeology.
When a member revokes consent, does any downstream system continue operating on their data for more than a few minutes? If yes, the consent table is not the source of truth for downstream systems.
Is the exact text the member saw recoverable today? The hash column plus an immutable policy-text store is the cleanest answer. A reference to a CMS slug that can be edited later is not an answer.
If all four pass, the consent architecture is solid. If any fail, start with the append-only table and work outward. Retrofitting consent from state to event is doable, and the migration is usually a matter of backfilling events from whatever audit trail exists, with an explicit "reconstructed" marker on the backfilled rows so auditors can distinguish reconstructed history from natively-captured events.
FAQ
Does HIPAA require a specific consent model or just a signed authorization?
HIPAA authorization under 45 CFR 164.508 requires specific elements (description of information, who may use it, expiration, right to revoke, member signature) but does not mandate an implementation pattern. The event-sourced model described here satisfies those requirements comfortably and adds the audit defensibility that the regulation implies but does not specify.
How does this interact with GDPR consent requirements?
GDPR Article 7 requires that consent be demonstrable, which maps almost directly to the event-sourced pattern. The evidentiary standard is similar to HIPAA's: who consented, when, to what, under what text. A single consent event table can satisfy both regimes if it captures the required fields for each. Label the consent_type values explicitly so the GDPR-relevant ones are distinguishable from HIPAA-authorization ones.
What happens when the privacy policy has a purely cosmetic change, like a typo fix?
The hash changes, so the consent events that were captured before the fix are tied to the pre-fix text. This is the correct behavior. If the team decides a typo fix does not warrant fresh consent, a separate policy-versioning doc can note that v1.2 and v1.2.1 are equivalent for consent purposes. The database still has both hashes; the equivalence is a legal judgment, not a schema change.
Should the consent table live in the same Postgres as the application or a separate one?
Same Postgres is fine for most teams. The separation is by schema and role, using the same append-only pattern as the audit log. A dedicated role has INSERT only on consents.events. The application's main database user has no access to consents. A consent-reader role has SELECT and is used by the intake flow and any downstream cleanup workers.
How do we handle consent for minors or for third parties consenting on a member's behalf?
Add fields for the consenting party's identity and their relationship to the member. A separate consent_type like 'authorized_representative' records this explicitly. Each consent event still captures the full text, policy version, and context; the additional fields just clarify who is on the hook. A healthcare attorney should ratify the specific model for your product because the rules vary by state and by the nature of the service.
Can we use a third-party consent management platform and skip building this?
Sometimes. Consent management platforms designed for GDPR/CCPA web consent (cookie banners, marketing preferences) are typically not designed for HIPAA authorization, which has stronger evidentiary requirements. If you use one, confirm it offers a BAA, that its consent records are append-only and exportable, and that the text-of-consent is captured not just a pointer. Most tools in this space fall short on the last point.
Sources and specifics
- HIPAA authorization reference: 45 CFR 164.508. Breach notification: 45 CFR 164.410. Audit controls: 45 CFR 164.312(b).
- GDPR evidentiary reference: Article 7 of the General Data Protection Regulation.
- Schema examples are Postgres 15+; the append-only property comes from role permissions, not schema features, and applies to any Postgres or Postgres-compatible database.
- Patterns observed across three regulated intake implementations, 2024-2026; all anonymized, no client-identifying metrics included.
- The event-sourced model handles renewal and revocation as first-class operations; retrofitting from a state-based model requires backfilling with a reconstructed-origin marker.
