Skip to content

Part 7 of 7 · Consent preference keeper series ~7 min read

Engineering reference: the consent preference keeper architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the immutability guarantee, and the fail-closed contract.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The consent preference keeper drawn with AWS service namesThree boxes across the top outside the AWS account. Consent points, meaning forms, unsubscribe links and staff entry. Sending systems, which ask before every send. And Downstream lists, which are pushed to and verified. Inside the account, three groups. A Function URL exposing record, resolve and resolve-bulk. Three Lambda functions named record, resolve and propagate. And two DynamoDB tables named events and state. A note gives the region as us-east-1, one account, and states that the events table has no update or delete path in any role.AWS ACCOUNTConsent pointsforms, links, staffSending systemsask before every sendDownstream listspushed and verifiedFunction URLrecord, resolve,resolve-bulkLambda x3record, resolve,propagateDynamoDB x2events, stateingroundsoutus-east-1. One account. The events table has no update or delete path in any role.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Database
  • App integration
  • Networking
  • Front-end & mobile
  • Outside AWS

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
cp-recordFunction URLAppends an event with its evidence; invalidates the cached state10s / 512 MB
cp-resolveFunction URLSingle and bulk resolution from the state table5s / 512 MB
cp-propagateSQS withdrawal queue + EventBridgePushes suppressions, reads them back, escalates at 24h, quarterly reconcile60s / 512 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
cp-record-roledynamodb:PutItem (events), dynamodb:UpdateItem (state)PutItem only on events — no UpdateItem, no DeleteItem
cp-resolve-roledynamodb:GetItem/BatchGetItemThe state table, read only
cp-propagate-rolesecretsmanager:GetSecretValue, dynamodb:UpdateItem (state)One secret per downstream system; the state table

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: events

PK   person            S   a stable id, not an email address
SK   at_purpose        S   2026-08-07T10:14:02Z#marketing
     state             S   granted | withdrawn | requested
     purpose           S   marketing | service | orders | research
     channel           S   email | sms | post | all
     source            S   checkout_form | preference_centre |
                           unsubscribe_link | staff_entry | import
     wording           S   the exact text shown, verbatim, inline
     proof             S   signed token, session id, or a staff name
     page              S   context only

APPEND ONLY. No role in this account has UpdateItem or DeleteItem on this
table, which makes immutability a policy fact rather than a convention.
A correction is a new event, never an edit.

Table: state

PK   person            S   the same stable id
SK   purpose_channel   S   marketing#email
     allowed           BOOL false
     since             S   2026-08-07T10:14:02Z
     from_event        S   the event SK that produced this
     downstream        M   {mailchimp: confirmed_at, crm: confirmed_at, ...}
     unconfirmed_since S   set when a push has not been verified

This is a cache, derived entirely from events. It can be rebuilt from the
event log at any time, and the rebuild is a useful thing to run after any
change to how state is computed.

Inbound and outbound

  • Unsubscribe links carry a signed token identifying the person, the purpose and the campaign. It is single-use for recording but the page remains usable, so a second click shows the current state rather than an error.
  • The resolve endpoint has a hard latency budget and sending systems are configured to treat a timeout as no. That client-side default is where fail-closed actually lives.
  • Bulk resolution returns the allowed subset, not a verdict per person, so a caller cannot accidentally iterate over the ones that were excluded.
  • Each downstream system has its own secret and its own entry in the register recording whether it can be pushed to at all.

The model call

  • There is no model in this system, and there should never be one. Every decision here is a lookup over an event log.
  • The one place somebody will suggest one is interpreting a free-text unsubscribe request in a reply email, and even there a keyword match plus a human is the right answer.
  • Consent is not a probabilistic question. A resolver that is ninety-nine per cent accurate is one that contacts a hundred people who withdrew, per ten thousand sends.
  • Determinism is the product. The same person and purpose must produce the same answer every time, and be explainable from the events that produced it.
  • The cost page assumes none, which is why this is one of the cheapest systems in the series.

Things worth knowing before you build it

  • Deny UpdateItem and DeleteItem on the events table in the IAM policy. Immutability enforced by convention is immutability that will eventually be broken by a migration script.
  • Store the wording verbatim. A reference to a page or a version number is worthless once the page is rewritten, which is the whole point of keeping evidence.
  • Invalidate the cache on write rather than using a short TTL. It gives an instant withdrawal and a long cache at the same time.
  • Read suppressions back from every downstream system. An API returning success is not the same as a person being suppressed.
  • Test that a dead resolver stops sends. Fail-closed lives in the client’s timeout handling, and almost nobody has actually verified it.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts