Skip to main content

Context

Ley 21.719 comes fully into force on 2026-12-01, and the point that shapes this design is that it audits operational evidence, not policies. A boolean column saying email_opt_in = true proves nothing: it cannot say when, how, or what the person was actually shown. So consent is an entity with history. Every grant and every revocation is a row with proof and a timestamp, and the current state is derived from the latest row. Overwriting is what destroys the evidence, so overwriting is forbidden. This also has direct commercial weight: consent defines the marketable contact, which is the billable base for the whole suite (ADR-014). A revocation reduces next cycle’s bill with no penalty window — that is a pricing promise implemented here.

Scope (normative)

  • core.consents: append-only history per (contact, channel, purpose).
  • core.consent_purposes: parametric — transactional, marketing, product.
  • Proof capture: source, IP, user agent, the text version shown, the timestamp.
  • Current-state resolution: the latest row wins.
  • The marketable-contact predicate used by metering and by messaging.
  • Grant and revoke endpoints, plus a member-facing preference read.

Non-scope (normative)

  • Deciding whether to send. messaging reads consent; the decision lives in resolveChannels.
  • Suppressions, which are a different thing: consent is what the person chose, a suppression is what the provider or the system imposed after a hard bounce. They must never be conflated.
  • The preference-centre UI — frontend/portal.

Behaviour (normative)

  1. Consent is append-only. A revocation is a new row, never an update. FORBIDDEN: a mutable boolean anywhere representing consent.
  2. Every row carries proof: source, captured timestamp, and the identifier of the text version the person was shown. Consent without proof is not consent.
  3. Current state per (contact, channel, purpose) is the row with the greatest captured_at. Ties are impossible: the column is timestamptz with microsecond precision plus a monotonic sequence.
  4. Transactional purpose is not opt-out (DEC-E3). A person who bought something receives their receipt. Only a hard bounce suppression stops it.
  5. Absence of consent is not consent. The default state for marketing is denied, everywhere, with no per-tenant override.
  6. A revocation takes effect immediately — before the next send decision, not on the next nightly recompute.
  7. A marketable contact is one with an active grant on ≥1 channel for a non-transactional purpose, and not suppressed. That predicate is defined here once and consumed by metering.
  8. Consent survives a merge: the survivor inherits the union, and where the two sides conflict the most restrictive wins. Inheriting a grant the person never gave for that record is the exact failure this rule exists to prevent.
  9. Erasure deletes consent rows with the profile, but the aggregate proof-of-processing record in the audit log survives (DEC-J3).

Data (normative)

API (normative)

Events (normative)

core.consent.granted and core.consent.revoked, both available as outgoing webhooks. A revocation is one of the few events a tenant almost always wants to mirror into their own systems.

Acceptance criteria (normative)

  1. Granting then revoking then granting again produces three rows, and the current state is granted.
  2. No code path updates or deletes a consent row — proven by a schema-level assertion.
  3. A revocation is honoured by the very next send decision, verified end to end.
  4. A contact with no consent row is not marketable and receives no marketing message.
  5. Transactional messages are delivered to a contact who revoked marketing.
  6. After a merge where one side granted and the other revoked the same channel and purpose, the result is revoked.
  7. The marketable-contact count matches an independently computed control set over a seeded population of 50 000.
  8. Negative: no endpoint or configuration allows opting a contact into marketing by default.

Execution

Single slice, synchronous command. The marketable predicate lives in packages/core as a pure function so metering and messaging cannot drift apart on it.

Open questions

Changelog

Delivery record

Not implemented yet.