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
- 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
devand aprodstack 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
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
cp-record | Function URL | Appends an event with its evidence; invalidates the cached state | 10s / 512 MB |
cp-resolve | Function URL | Single and bulk resolution from the state table | 5s / 512 MB |
cp-propagate | SQS withdrawal queue + EventBridge | Pushes suppressions, reads them back, escalates at 24h, quarterly reconcile | 60s / 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
| Role | Allowed | On |
|---|---|---|
cp-record-role | dynamodb:PutItem (events), dynamodb:UpdateItem (state) | PutItem only on events — no UpdateItem, no DeleteItem |
cp-resolve-role | dynamodb:GetItem/BatchGetItem | The state table, read only |
cp-propagate-role | secretsmanager: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