Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/trackingv0.7.1· Specification

spec-consent-receipts — Server-Side Audit Trail

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.

Status: mechanism implemented (2026-07-18); a backend client and legal sufficiency still the consumer's. The package now ships the receipt model (ConsentReceipt/ConsentReceiptInput + isConsentReceiptInput), a pluggable ReceiptStore with an in-memory reference (createInMemoryReceiptStore) and a durable adapter over an injected backend (createDurableReceiptStore), the server builder (buildConsentReceipt + coarsenIp) with an optional integrity sign seam, and the TanStack Start endpoint helper (recordConsentReceiptFromRequest); the reference app wires an HMAC signer over that seam. What remains for a production deployment: a backend client to inject into createDurableReceiptStore (the in-memory one is dev-only), a configured retention period, an optional notice-snapshot store, an HMAC/signature key for the signer, and counsel review of legal sufficiency. This document remains the contract those pieces satisfy. Access dates for all citations: 2026-07-18. Items flagged UNCERTAIN need legal counsel review before reliance.

1. Why a client record is not proof

The controller carries the burden of proving consent (GDPR Art. 7(1); Recital 42 — gdpr-info.eu/art-7-gdpr). The operative standard is EDPB Guidelines 05/2020 on consent, §5.1 (paras 104–110). Para 108 is decisive:

a controller should retain, in an online context, information on the session in which consent was expressed, documentation of the consent workflow at the time, and a copy of the information presented to the data subject — and "it would not be sufficient to merely refer to a correct configuration of the respective website."

A tamperable client ConsentRecord in local storage is exactly such a "website configuration": it can be cleared, forged, or diverge across devices. It therefore MUST NOT be the record of proof. Analogous burden-of-proof duties: Brazil LGPD Art. 8 §2; Vietnam PDPL (Law 91/2025/QH15, in force 2026-01-01) Art. 9 (consent) / Art. 10 (withdrawal) — verified 2026-07-18, the enacted-law numbering, superseding the predecessor Decree 13/2023's Art. 11–12; both require a printable/verifiable format; Quebec Law 25 s.14 (UNCERTAIN — precise record-keeping article).

The five dimensions a receipt MUST make demonstrable: who consented, when, to what (purposes), how (the workflow/method), and under which notice text/version presented at that moment.

2. The receipt data model

"Required" is derivable from EDPB §5.1 para 108 + Art. 7 burden. "Recommended" strengthens the proof per ISO/IEC TS 27560:2023 (Consent record information structure — iso.org/standard/80392.html) and market practice.

Field Req? Basis PII sensitivity
receiptId (UUID/ULID) MUST ISO 27560 record id; correlates client ↔ server Low (pseudonymous key)
schemaVersion MUST ISO 27560 conformsTo None
subjectId MUST* EDPB "who"; Art. 7(1) High — the linking id; a pseudonymous cookie id for anonymous visitors
subjectIdType (cookie/userId/email-hash) MUST ISO 27560; OneTrust identifierType Medium
timestamp (epoch ms, UTC) MUST EDPB "when" Low
decision ({ ads, analytics }) MUST EDPB "to what"; reuses spec-consent §1 shape Low
purposes ({ id, version }[]) MUST ISO 27560 purpose + version resolved by consent date Low
policyVersion MUST EDPB "under which policy text" None
noticeVersion + noticeSnapshotRef MUST EDPB para 108 "copy of the information presented" None (but the snapshot store must exist)
noticeLanguage (BCP-47) MUST EDPB informed-consent None
method (banner-accept/banner-reject/granular/gpc-signal/withdrawal) MUST ISO 27560 method of expression; EDPB "how" None
eventType (give/update/withdraw) MUST ISO 27560 consent events; PDPL Art. 10 None
sourceUrl SHOULD Cookiebot; session context Low (strip query string)
userAgent SHOULD market practice Medium — store browser/OS family only
ipCoarse (truncated/pseudonymized) SHOULD Cookiebot bit-truncation; TrustArc pseudonymization High if full — never store full IP
jurisdiction (ISO 3166) SHOULD ISO 27560 hasJurisdiction; drives retention Low
integrityKey (HMAC/signature) SHOULD Cookiebot tamper-verification None
retentionUntil SHOULD EDPB para 107 proportionality None

* subjectId is conceptually required (something must link the receipt to a person/session); it MAY be a pseudonymous cookie id for anonymous visitors.

Market corroboration (vendor docs, primary only for "what this vendor stores"): Cookiebot stores a Consent ID + state + timestamp + UA + submission URL + anonymized IP (last 16 bits IPv4 / 96 bits IPv6 removed) + an integrity key; Didomi retains the full notice configuration as presented, UA + parsed device, 5-year retention; OneTrust's Create Consent Receipt API links purpose-version and notice-version by consent date; TrustArc pseudonymizes full IP immediately, 13-month default. IP is consistently minimized, never stored raw.

3. Server endpoint contract

POST /api/consent/receipts        # write a receipt at consent time
  Body: receipt input (model above minus server-derived fields)
  Server derives: receiptId, authoritative timestamp, ipCoarse (from the
                  socket, truncated server-side), integrityKey
  → 201 Created { receiptId, integrityKey, retentionUntil }

GET  /api/consent/receipts/:id     # auth-gated read for the controller's DSAR handler

Because the response carries PII, the endpoint MUST:

  • Set Cache-Control: no-store and carry no CDN caching (s-maxage, CDN-Cache-Control must be absent) — it is a function route that must bypass any CDN cache. This is the same per-visitor no-store discipline as spec-server-lane §2, and applies with more force because a receipt is PII, not just a region default.
  • Derive ipCoarse server-side from the connection, never accept an IP from the request body (client-supplied IP is untrustworthy, and full IP must not transit). Reject bodies carrying a full IP.
  • Be append-only: an update or withdrawal is a new receipt with a new eventType referencing the prior receiptId; existing receipts are never mutated in place.
  • Never place subjectId or receipt data in a URL query string (path param, opaque ids only) — consistent with the privacy rules in the root spec.

4. Retention and minimization

  • R1 (MUST) — retain the receipt while the associated processing is active. (EDPB §5.1 para 107.)
  • R2 (MUST) — after processing ends, delete once no longer strictly necessary for a legal obligation or to establish/exercise/defend legal claims. (EDPB para 107; GDPR Art. 17(3)(b),(e).)
  • R3 (MUST) — the retention period MUST be configurable per jurisdiction (default SHOULD track the local limitation period for claims, commonly ~3–6 years), never indefinite and never hard-coded. (EDPB para 110: no fixed statutory period; market: TrustArc 13 mo, Didomi 5 yr.) — the exact number is UNCERTAIN per jurisdiction, a legal-review call.
  • R4 (MUST) — the receipt MUST NOT collect more personal data than necessary to evidence the consent link. (EDPB para 106 — a minimization ceiling on the receipt itself.)
  • R5 (MUST) — a full IP address MUST NOT be persisted; only a truncated/pseudonymized form.
  • R6 (SHOULD)userAgent SHOULD be reduced to browser/OS family unless the full string is justified as evidence.
  • R7 (MUST) — withdrawal events MUST be retained in the same provable, exportable format as grants. (PDPL Art. 10; GDPR Art. 7(3).)

5. Authoritative-source rule

The client ConsentRecord (local storage) is authoritative only for runtime UX gating on the current device — a cache of the decision used to gate ad/analytics firing. The server receipt is the sole authoritative record for legal proof. On any divergence, the server receipt governs for compliance and audit; the client governs only "what to do right now on this device." A missing, cleared, or tampered client record triggers re-prompting (per spec-consent §6) and never invalidates a stored server receipt. The client SHOULD carry the receiptId (+ integrityKey) so its state can be verified against the stored original.

Conformance vectors

V1 — grant, both categories. Input { decision:{ads:true,analytics:true}, policyVersion:"2026-05", noticeVersion:"banner-v3", lang:"en" } → stored receipt has eventType:"give", method:"banner-accept", purposes resolved to their versions as of the timestamp, server-set receiptId/timestamp/integrityKey, ipCoarse truncated; response Cache-Control: no-store.

V2 — granular. decision:{ads:false,analytics:true}, method:"granular" → the exact per-category split is recorded, never collapsed to one boolean.

V3 — GPC. method:"gpc-signal", decision.ads:false, analytics unaffected — matches the GPC-denies-ads-only rule (spec-consent §3).

V4 — withdrawal. A new receipt eventType:"withdraw" referencing the prior receiptId; the original grant receipt remains intact (append-only) and the withdrawal is itself printable/verifiable.

V5 — full IP rejected. A body carrying a full IP is rejected/ignored; the server persists only its own truncated ipCoarse.

V6 — anonymous visitor. subjectId = pseudonymous cookie id, subjectIdType:"cookie"; the receipt is still valid.

V7 — retention expiry. A receipt past retentionUntil with no active processing or claim hold is purged (R2/R3); one under an active claim hold is retained past the default.

V8 — notice snapshot integrity. Given noticeVersion:"banner-v3", noticeSnapshotRef MUST resolve to the exact text presented (EDPB para 108); a dangling ref is a conformance failure.

Quebec Law 25's precise record-keeping article vs. its s.14 validity provision; and the concrete retention number per target jurisdiction (make it configurable, do not hard-code). (Vietnam's PDPL numbering — Art. 9 consent / Art. 10 withdrawal — was resolved in the 2026-07-18 verification pass; see §1.)

License

Released under the MIT License.