> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Consent as History

> After this ships, we can prove — with a timestamp and evidence — that a specific person agreed to be contacted on a specific channel for a specific purpose, and exactly when they stopped agreeing.

## 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)*

| Table                   | Key invariants                                                                                                                                                                                                                                             |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.consents`         | append-only; FK `contact_id`, `channel_code`, `purpose_code`; `status` granted\|revoked; `captured_at` timestamptz; `source`, `ip`, `user_agent`, `text_version`; index on (`tenant_id`, `contact_id`, `channel_code`, `purpose_code`, `captured_at DESC`) |
| `core.consent_purposes` | parametric; `is_system` seeds `transactional`, `marketing`, `product`; not tenant-extensible                                                                                                                                                               |

## API *(normative)*

| Endpoint                               | Class      | Permission            | Budget       |
| -------------------------------------- | ---------- | --------------------- | ------------ |
| `POST /v1/core/contacts/{id}/consents` | Management | `core.consents.write` | p95 \<1 s    |
| `GET /v1/core/contacts/{id}/consents`  | Management | `core.consents.read`  | p95 \<1 s    |
| `GET /v1/core/members/me/preferences`  | Runtime    | member token          | p95 \<150 ms |
| `PUT /v1/core/members/me/preferences`  | Runtime    | member token          | p95 \<300 ms |

## 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

| # | Question                                                                                      | Decides          | By                       |
| - | --------------------------------------------------------------------------------------------- | ---------------- | ------------------------ |
| 1 | Does consent expire after a period of inactivity, as some interpretations of the law suggest? | Daniel + abogado | before commercial launch |
| 2 | Do we version consent text ourselves, or is the version an opaque string the tenant supplies? | Daniel           | before approval          |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
