xavier-ramirez.com
← Profiles & propensity

Your own activation platform · 04 · 23 Aug 2026 · 11 min read

Consent, isolation & deletion

Consent is a join key, and deletion has to fan out.

The tempting version of privacy is a checklist you satisfy at the end. You build the pipeline, then add a consent check before the sync call, then write a runbook for handling deletion requests by hand. It works — right up until someone adds a second export path, or a hurried refactor drops one clause from one query, and nothing anywhere goes red. This post shows the other version: consent as a join key that audience compilation cannot compile without, isolation with a row policy underneath it, and deletion as an orchestrated fan-out with a receipt per step.
01 · Spine02 · Identity resolution03 · Profiles & propensity04 · Consent & deletion05 · Activation & match rates06 · Measurement

This post deepens the governance layer that wraps every stage — collection, resolution, profiles and activation.

Worked exampleThis series uses a multi-brand customer data & activation platform as its running example — clickstream from direct-to-consumer storefronts, orders, ad spend, and the audiences the platform pushes back out to ad and email destinations. The pipeline itself is domain-agnostic; the data just happens to belong to commerce brands.

Consent

A join key, not a filter you remember to apply

Both shapes below return exactly the same five profiles today. Only one of them is still correct on the evening someone deletes a line while chasing an empty-audience bug.

compile the audience
Consent is modelled as…
Friday evening

↳ Try to delete a line in each shape. One of the two will not let you.

The query
SELECT t.profile_id
FROM profile_traits AS t
INNER JOIN consent_current AS c
  ON c.profile_id = t.profile_id
WHERE t.p_purchase_7d >= 0.62
  AND c.marketing = 1
  AND c.sale_share = 1
  AND c.captured_at >= now() - INTERVAL 24 MONTH
Who compiles into the audience — 2 of 5
  • prof_8812eligible
  • prof_2140sale_share = 0
  • prof_6631marketing = 0
  • prof_9075captured 31 months ago
  • prof_3318eligible
The consent table is an inner join, so membership is the result of a query that cannot execute without it. There is no code path that forgets, because there is no code path at all.

That is the structural move, and it is the whole section. Audience compilation joins the consent table, so membership is the result of a query that cannot execute without it. There is no code path where someone forgets, because there is no code path at all. The consent predicates themselves are appended by the compiler from what the destination declares it requires — never hand-typed into an audience definition.

And consent is not a boolean. It is a small record with a jurisdiction, a timestamp and a provenance, and every one of those fields exists because one day someone will ask you a question the boolean cannot answer.

src/consent/types.ts
interface ConsentState {
  analytics: boolean;
  marketing: boolean;
  saleShare: boolean;          // "sale or sharing", in the US state-law sense
  jurisdiction: string;        // "US-CA", "EU-DE"
  capturedAt: string;          // ISO 8601
  source: "banner" | "gpc" | "preference_center" | "import";
}
sql/audience_compile.sql
SELECT t.profile_id
FROM profile_traits AS t
INNER JOIN consent_current AS c
  ON c.brand_id = t.brand_id AND c.profile_id = t.profile_id
WHERE t.brand_id = {brandId:String}
  AND t.env      = {env:String}
  AND t.days_since_last_order <= 30
  AND t.p_purchase_7d >= 0.62
  AND c.marketing  = 1        -- destination-required basis, injected by the compiler
  AND c.sale_share = 1
  AND c.captured_at >= now() - INTERVAL 24 MONTH
Why not just filter unconsented profiles at sync time?
Because by then the profile has already been computed into an audience, written to a membership collection, logged with counts, cached for the preview endpoint, and possibly rendered in a screen where a marketer read it. The harm is not only the outbound request. And the sync path is not the only path out of your platform — there is a spreadsheet export, a webhook destination, an analyst with warehouse credentials and a query. Enforce at the narrowest point every path shares, which is the compilation of membership itself, not the last thing that happens to it.

Signals

A browser opt-out is honoured at collection, not at activation

A universal opt-out signal arrives as a request header on the very first call. The right time to act on it is before the event exists downstream: the collector marks the event as not shareable and publishes that, so nothing later has to know the signal is a thing.

src/collector/handler.ts
export function resolveConsent(req: FastifyRequest, claimed: ConsentState): ConsentState {
  // A universal opt-out signal, sent by the browser as a request header and
  // legally recognised in several US states. Honour it here, at the earliest
  // point where the data might still not exist.
  const optedOut = req.headers["sec-gpc"] === "1";
  if (!optedOut) return claimed;
  return {
    ...claimed,
    saleShare: false,
    marketing: claimed.jurisdiction.startsWith("EU-") ? false : claimed.marketing,
    source: "gpc",
    capturedAt: new Date().toISOString(),   // server-stamped, like every ts in the envelope
  };
}

The principle generalises well past this one header: enforce at the earliest point where the data still might not exist. Every layer you push enforcement downward is another copy you have to remember to clean.

Isolation

Brand and environment are partition keys, and the agency role is why

Every document, row and query carries a brand and an environment, and storage is namespaced by both. Post 1 defined that type and threaded it through; this post adds the layer underneath it, for the case where your application code is not the thing making the query.

sql/row_policies.sql
CREATE ROW POLICY agency_brand_scope ON activation.profile_traits
  FOR SELECT USING brand_id = currentSetting('SQL_brand_id')
                AND env      = currentSetting('SQL_env')
  TO agency, analyst, marketer;

-- admin is deliberately excluded: break-glass access is logged, not silently unfiltered.
CREATE SETTINGS PROFILE brand_bound SETTINGS
  SQL_brand_id = '' READONLY, SQL_env = '' READONLY;

The session settings those policies read are populated from signed token claims — never from a request parameter, a header the client controls, or anything a screen passed along.

src/security/scope.ts
import type { BrandScope } from "../contracts/scope.js";

export function scopeFromClaims(claims: JwtClaims): BrandScope & { role: Role } {
  const roles = claims.roles ?? [];
  if (roles.length === 0) throw new ForbiddenError("no roles in token");   // fail closed
  const role = pickHighest(roles);
  if (!claims.brand_id || !claims.env) throw new ForbiddenError("unscoped token");
  return { brandId: claims.brand_id, env: claims.env, role };
}

An empty role list is a rejection, not a default. The failure mode you are avoiding is the one where a misconfigured identity provider stops emitting a claim and every token quietly becomes an unfiltered one.

Deletion

A deletion that stops at your warehouse is not a deletion

A request or a consent revocation lands as a tombstone for one profile. Deleting the rows is the easy part and the least of it. Run the fan-out below with the ledger switched off — the case where every internal step succeeds and the deletion still did not happen.

the fan-out
Per-profile sync ledger
Destination API

↳ Start with no ledger — the case where every internal step succeeds and the deletion still did not happen.

Request statusreported complete
Reported complete, and it is not. Nothing internal still holds the profile — but no record exists of where it was sent, so three destinations keep serving against the keys you uploaded.

That last bullet is the point of the post. You can only remove a profile from a destination if you know it was ever sent there — which means a per-profile ledger row written at activation time, long before the first request, and if you skip it no amount of later engineering recovers the history. The ledger itself is post 5’s; here it is simply the table you read.

sql/sync_ledger_lookup.sql
SELECT destination, audience_id, argMax(sync_id, ts) AS last_sync_id, max(ts) AS last_sent_at
FROM sync_ledger
WHERE brand_id = {brandId:String} AND profile_id = {profileId:String}
GROUP BY destination, audience_id
src/deletion/orchestrator.ts
export async function runDeletion(scope: BrandScope, profileId: string): Promise<DsarResult> {
  const receipts: StepReceipt[] = [];
  for (const step of STEPS) {           // ordered: warehouse, graph, membership, ledger, destinations
    try {
      receipts.push(await step.run(scope, profileId));
    } catch (err) {
      // Do not swallow. The request stays open and visible until every step has a receipt.
      receipts.push({ step: step.id, status: "failed", error: String(err), at: nowIso() });
      await dsarStore.markIncomplete(scope, profileId, receipts);
      throw new DsarIncompleteError(profileId, step.id);
    }
  }
  await dsarStore.markComplete(scope, profileId, receipts);
  return { profileId, status: "complete", receipts };
}

When a destination’s removal call fails — a rate-limit response, an expired token, a maintenance window — you retry with backoff, and until it succeeds the request is not complete. That state has to be visible: an open-request gauge with an age, alerting past your regulatory clock, not a swallowed exception in a log nobody reads. The silent failure here is the nastiest in the series, because a half-finished deletion looks exactly like a finished one from the inside.

An awkward true thing about other people’s systems

Some destinations accept a removal and give you no way to verify it landed. You cannot prove the profile is gone from someone else’s system; you can prove you asked. So record the request payload hash, the response status and the response body as the receipt, and describe it in exactly those terms in your own documentation. Overclaiming here is how a compliance answer becomes a false statement.

Audit

A trail, not a second copy of the data

One append-only line per activation sync and per deletion. It is deliberately boring.

audit.log
{"ts":"2026-08-19T14:22:07Z","brand":"northwind","env":"prod","actor":"sync-engine",
 "audienceId":"aud_high_intent_30d","destination":"meta","syncId":"sync_7f31","adds":1842,
 "removes":210,"rejected":37,"matchRate":0.671,"consentBasis":["marketing","saleShare"],
 "holdoutBps":1000,"experimentId":"exp_prospecting_q3"}

This is the artefact that turns a described control into a demonstrated one: the sync line shows the consent basis was evaluated, and the deletion receipts show the fan-out reached every destination the ledger named. You are not building a compliance product. You are making the system’s own behaviour legible enough that an auditor’s question has an answer that is a query rather than a meeting.

Run it yourself · open-activation-platform @ v4-consent
Sync an audience, revoke one profile’s consent, and watch the removal propagate — then run a deletion and read the receipts.
terminal
git clone https://github.com/xaviramirezcom/open-activation-platform
cd open-activation-platform
git checkout v4-consent
pnpm install
pnpm activate up            # streaming + warehouse + config store, via docker compose
pnpm activate seed          # synthetic multi-brand traffic, orders and spend

pnpm activate sync --audience aud_high_intent_30d --dest meta --brand northwind
pnpm activate consent:revoke --profile prof_8812 --field saleShare --brand northwind
pnpm activate sync --audience aud_high_intent_30d --dest meta --brand northwind

pnpm activate dsar --profile prof_8812 --brand northwind --print-receipts
The second sync is the whole argument in one diff: the profile appears in the removes, not because anything checked for a revocation, but because the compile-time inner join stopped producing the row and the diff engine saw a member disappear. The deletion run then prints five receipts — the warehouse mutation id, the component split, the membership count, the ledger rows read, and one line per destination with the status of the removal request. Kill the mock destination adapter before running it and the last receipt fails, the request is marked incomplete, and the open-request gauge starts ageing.

Teaching-grade reference implementation, not a production customer data platform. It reproduces the ideas and the streaming/warehouse integration shape; bring your own data and destination credentials. Destination adapters run against a local mock by default. MIT-licensed. View the repo →

Explain it back

A team implements deletion carefully: warehouse rows gone, membership dropped, identity component split and re-pinned. An internal audit passes. Eight months later a customer who filed a deletion request — and was told it was complete — receives a retargeting ad from the brand. Nothing in the platform still holds their data. Where did the deletion leak, and what should have existed from day one?
Reveal a model answer

The deletion never left the building. Every step was internal; none of them told a destination anything. Those destinations keep their own copies of the hashed keys you uploaded, and they keep serving against them until you issue a removal. The leak is the missing fan-out step.

The fix is not a new deletion step — it is the thing that makes the step possible: a per-profile sync ledger, written at activation time, recording every profile, destination, audience and sync id you ever pushed. Without it, after the internal deletion you no longer know where the profile went, and you cannot reconstruct it.

Bonus consequence: the request was reported complete, so the completion signal was wrong too. Completion must require a receipt per destination, with retries and an open-request gauge that ages, or you are certifying work you never did.

Next in the series · 05
Activation & match rates

Now that membership is consented, scoped and revocable, the next post pushes it out — and measures how much of it the destination actually recognises.

Diff, do not replace