diff --git a/docs/rfcs/0002-explicit-consent-at-signup.md b/docs/rfcs/0002-explicit-consent-at-signup.md
new file mode 100644
index 000000000..6d0b7334e
--- /dev/null
+++ b/docs/rfcs/0002-explicit-consent-at-signup.md
@@ -0,0 +1,663 @@
+# RFC 0002: Explicit consent at signup
+
+| | |
+|---|---|
+| **Status** | Draft. Not implemented. |
+| **Author** | Rohan Chakraborty |
+| **Created** | 2026-08-26 |
+| **Updated** | 2026-08-27 |
+
+## Summary
+
+Frontier can require a user to accept a set of documents before their account is created, and store
+one consent record for it.
+
+A deployment lists its documents in server config with an id, title, version and URL. A public
+endpoint serves that list, and the client sends back the ids the user accepted. Frontier checks that
+the ids cover every document in config, then creates the user and the consent record in one
+transaction. The record copies each document's version and URL from config.
+
+Consent applies to signups only, and frontier cannot tell a signup from a login today. So this RFC
+also adds a flow intent, which is what lets the consent check run before the browser leaves for the
+identity provider.
+
+With `app.consent` disabled, nothing changes. Frontier never reads document contents and never
+parses version strings.
+
+## Problem
+
+Frontier creates a user at the end of a registration flow and stores nothing about what the user
+agreed to. Four gaps.
+
+Finishing signup is the only evidence of agreement, and it proves nothing. It cannot separate a user
+who read the documents from one who never saw them.
+
+Nothing answers "which documents did this user accept, and when". `users.metadata` cannot hold it,
+because `UpdateUser` and `UpdateCurrentUser` replace the whole map, so a user could delete their own
+consent by editing their profile.
+
+Documents are versioned, but nothing records which version a user accepted.
+
+Frontier does not know which requests are signups. `SignInView` and `SignUpView` are the same view
+with different strings, `AuthenticateRequest` and `Flow` carry no intent, and every strategy ends at
+`getOrCreateUser` (`core/authenticate/service.go:784`), which returns the existing user or creates
+one. So a login with an unknown address creates the account, through a view that never showed the
+documents.
+
+## Goals
+
+- The client sends consent explicitly. Signup fails without it.
+- No API can update or delete a consent record. Deleting the user does not delete it.
+- A record stores the version and URL of every document it covers.
+- Document ids, titles, versions and URLs come from config, and the client reads them from frontier.
+- A login never creates an account. A signup never logs an existing user in.
+- The auth flow stays one RPC and one callback, plus one read-only endpoint for the list.
+- Deployments without `app.consent`, and clients without an intent, behave as before.
+
+## Non-goals
+
+Re-consent and withdrawal. When a version changes, old records keep the old version and nobody is
+asked again.
+
+Consent for users who already exist, or for the three paths that create a user without a flow. A
+record is written at user creation and nowhere else.
+
+Making the login gate a security boundary, or hiding whether an address has an account. Limitations
+says what both cost.
+
+Serving the document text. The list endpoint returns URLs, and frontier never reads what is behind
+them.
+
+## Document config
+
+A map under `app.consent`, keyed by document id:
+
+```yaml
+app:
+ consent:
+ enabled: true
+ documents:
+ terms_of_service:
+ title: Terms & Conditions
+ version: "2026-04-01"
+ url: https://example.org/legal/terms/2026-04-01
+ privacy_policy:
+ title: Privacy Policy
+ version: "2026-04-01"
+ url: https://example.org/legal/privacy/2026-04-01
+```
+
+`app.consent` sits next to `app.authentication` and `app.pat` on `server.Config`. The example lists
+two documents; a deployment lists all of its own.
+
+A map, not a list: it matches `authenticate.Config` keying `oidc_config` by strategy name, the key
+enforces unique ids, and a single field stays env-overridable.
+
+Every document in the map is required at signup, so there is no per-document `required` flag. An
+optional document would need withdrawal, which is out of scope. Adding a document id breaks signup
+for a client sending a hardcoded list, which is why the list is served over an endpoint. Version
+bumps are safe either way, since the client sends only ids.
+
+`version` is opaque. Frontier compares it for equality, so dates, semver or commit SHAs all work.
+
+`enabled` switches the whole feature. Absent or false, frontier behaves as it does today, and
+`accepted_document_ids` is ignored rather than rejected, so one client build works against both
+kinds of deployment. True with no documents fails at boot rather than silently disabling itself.
+
+Config is read at boot, so a version change needs a restart. An env override cannot alter an
+existing record, but it can produce wrong new ones, so the resolved set is logged at boot. That log,
+not the config repo, says what a deployment was serving.
+
+Bad config fails at boot: ids, versions and URLs must be non-empty, URLs must parse, and an enabled
+block needs at least one document.
+
+## The document list endpoint
+
+`ListConsentDocuments`, unauthenticated, so a sign-up view can render the documents it is asking the
+user to accept:
+
+```proto
+message ConsentDocument {
+ string id = 1;
+ string title = 2;
+ string version = 3;
+ string url = 4;
+}
+
+message ListConsentDocumentsRequest {}
+
+message ListConsentDocumentsResponse {
+ repeated ConsentDocument documents = 1;
+}
+```
+
+It returns the resolved config set, all four fields per document, ordered by id so the response is
+stable.
+
+With `app.consent` disabled it returns an empty list rather than an error, so one client build works
+against both kinds of deployment: no documents means no checkbox.
+
+The handler mirrors `ListAuthStrategies` (`internal/api/v1beta1connect/authenticate.go:302`). It
+reads the consent service, touches no database, and joins `authenticationSkipList` beside
+`ListAuthStrategies` and `Authenticate`.
+
+Public, because the documents are already public: the URLs are meant to be read by anyone
+considering an account, and the ids are an input to an unauthenticated `Authenticate`. Requiring a
+session to learn what to accept before the account exists is a cycle.
+
+Not folded into `ListAuthStrategies`. Consent is not a strategy, and `AuthStrategy` carries `name`
+and `params` and nothing else, so the documents would go in a `params` map every client has to
+parse. Two thin endpoints beat one that means two things.
+
+## The request fields
+
+Two new fields on `AuthenticateRequest`, authored in `raystack/proton` and generated here through
+`PROTON_COMMIT`:
+
+```proto
+enum FlowIntent {
+ FLOW_INTENT_UNSPECIFIED = 0;
+ FLOW_INTENT_LOGIN = 1;
+ FLOW_INTENT_SIGNUP = 2;
+}
+
+FlowIntent flow_intent = 6;
+repeated string accepted_document_ids = 7;
+```
+
+Both fields land in one proton PR, so neither can claim the other's number. An enum, not a string,
+because the set is closed, and its zero value gives backward compatibility for free.
+`AuthCallback` needs neither field, since both ride on the flow.
+
+The ids accompany `FLOW_INTENT_SIGNUP` only. With a login intent they are a client bug and the
+handler rejects them, because a login writes no record. Accepting them silently would leave a client
+believing it recorded a consent that does not exist.
+
+Ids rather than one boolean, because the list the client rendered can still differ from config: it
+may have been fetched before a restart, or hardcoded by a consumer rendering its own view. Ids
+expose the mismatch; a boolean would stamp whatever config holds, writing a record that says the
+user accepted a document they never saw. Ids also allow consenting to a subset later. Versions,
+titles and URLs all come from config, so a client sending them would be ignored. Duplicates are
+removed before the check.
+
+## Carrying them across the redirect
+
+In an OIDC flow the user accepts before the browser leaves for the provider, and the account is
+created after it returns:
+
+```mermaid
+flowchart TD
+ accept["User accepts the documents"]
+ auth["Authenticate: intent = signup,
ResolveAll on the ids"]
+ early["Reject with FailedPrecondition,
nothing written, no redirect"]
+ store["Intent, ids, IP and time
written to flows.metadata"]
+ idp[("Identity provider")]
+ cb["AuthCallback: flow row read back"]
+ q{"Does the user
already exist?"}
+ exists["The signup gate rejects it,
no consent record"]
+ create["One transaction: create the user
and the consent record"]
+
+ accept --> auth
+ auth -->|"incomplete"| early
+ auth -->|"complete"| store --> idp
+ idp -->|"state = flow id, code"| cb
+ cb --> q
+ q -->|"yes"| exists
+ q -->|"no"| create
+```
+
+Only `state` survives the redirect, and it already holds the flow id, so both fields go where the
+flow id points. `Flow.Metadata` is a JSONB column that already carries `callback_url`, so no
+migration is needed. `RegistrationStartRequest` gains the intent and the ids, and `StartFlow` writes
+two more keys:
+
+```go
+type FlowIntent string
+
+const (
+ FlowIntentUnspecified FlowIntent = ""
+ FlowIntentLogin FlowIntent = "login"
+ FlowIntentSignup FlowIntent = "signup"
+)
+
+flow.Metadata["intent"] = string(intent)
+flow.Metadata["consent"] = map[string]any{
+ "accepted_document_ids": ids,
+ "ip_address": ip,
+ "at": s.Now(),
+}
+```
+
+The flow row is written before the redirect and read after it returns, so neither value passes
+through the browser. The stored IP and time are from when the user accepted, not from the callback.
+Every strategy shares this path.
+
+`Authenticate` and `AuthCallback` are in `authenticationSkipList`, so nothing puts session metadata
+in the context. The handler calls `sessionutils.ExtractSessionMetadata` itself and passes the IP
+into `StartFlow`. That helper parses the user agent into an OS and browser family and drops the raw
+string, so the record keeps only the IP.
+
+JSONB does not return the types it was given: the ids come back as `[]any` and `at` as an RFC 3339
+string. One typed accessor per key handles the read, as `otpAttempts` already does, instead of an
+unchecked assertion like `flow.Metadata["callback_url"].(string)`. Both accessors are
+nil-receiver-safe methods on `*Flow`, so a caller without a flow needs no branch. A missing or
+unparseable consent key means no consent.
+
+One unrelated fix comes with this. `applyOIDC` never calls `consumeFlow`, so OIDC flow rows survive
+until the expiry cron while mail OTP rows are deleted on use. That is hard to justify once the rows
+hold consent.
+
+## Enforcement
+
+Two gates. `StartFlow` is the fast path and exists for the error message. User creation is the gate
+that matters: every strategy reaches it, and it is where the account would be created. OIDC does not
+know the email until the callback, so the login gate needs both points.
+
+### Login and signup
+
+| Intent | Strategy | At `StartFlow` | At user creation |
+|---|---|---|---|
+| login | mailotp, passkey | reject if no user exists | reject if no user exists |
+| login | oidc | email unknown, no check | reject if no user exists |
+| signup | mailotp, passkey | reject if a user exists | reject if a user exists |
+| signup | oidc | email unknown, no check | reject if a user exists |
+| unspecified | all | no check | create or get, as today |
+
+`StartFlow` currently guesses signup from login for passkey by looking the user up
+(`core/authenticate/service.go:220`), and `finishPassKeyLoginMethod` calls `getOrCreateUser`, so a
+passkey login can create an account. The intent replaces the guess: signup picks
+`startPassKeyRegisterMethod`, login picks `startPassKeyLoginMethod`, unspecified keeps the guess.
+
+### Consent
+
+The consent service owns the config, so it owns the checks. Four functions, because signup, the list
+endpoint and any later use need different rules:
+
+```go
+// Documents returns every document in config, ordered by id.
+// Empty when the feature is disabled. Serves ListConsentDocuments.
+func (s Service) Documents() []Document
+
+// Resolve maps ids to their config snapshots. Rejects unknown ids.
+// Says nothing about whether the set is complete.
+func (s Service) Resolve(ids []string) ([]Document, error)
+
+// ResolveAll is Resolve plus the completeness rule: the ids must cover
+// every document in config, no more and no less.
+func (s Service) ResolveAll(ids []string) ([]Document, error)
+
+// Grant writes one consent record for the documents given.
+// No completeness rule here at all.
+func (s Service) Grant(ctx context.Context, tx *sqlx.Tx, req GrantRequest) error
+```
+
+`ResolveAll` compares both sets in both directions, so the error names what is wrong: missing
+required ids, or ids config does not know. `Grant` takes what it is given, which leaves room for a
+re-consent covering one document without a second write path.
+
+The intent decides where the completeness check runs. Under `FLOW_INTENT_SIGNUP`, `Authenticate`
+knows it has a signup, so `ResolveAll` runs there, before the redirect and before mail OTP sends
+anything. This holds for every strategy including OIDC: the email is unknown at flow start, but the
+intent is not. Without an intent the check splits, since a signup and a login look identical:
+`Resolve` at flow start catches an unknown id before the redirect, and `ResolveAll` runs at user
+creation, the first point where frontier knows the user is new. An unset intent is permissive for
+the login gate but never for consent.
+
+`ResolveAll` runs at user creation either way, not for the error but as the invariant guarding the
+write. It is the last point before the insert, and it makes a user row without a consent record
+impossible.
+
+`getOrCreateUser` takes the flow, which is a signature change. The flow carries both the intent and
+the consent, so one parameter serves both gates, and the nil-safe accessors let
+`authenticateWithPassthroughHeader` pass nil without a branch. Then:
+
+- New user, `ResolveAll` passes: one transaction creates the user row and the consent record.
+- New user, `ResolveAll` fails: return `ErrConsentRequired`, create nothing.
+- Existing user: write nothing. Under a signup intent the login gate has already rejected the
+ request; without an intent they are logged in, whatever the flow holds.
+
+The third case is absolute. A record written outside a user creation would carry that moment's
+timestamp and IP for an agreement made elsewhere, which is worse than no record: it reads like
+evidence.
+
+Hence the transaction. `ResolveAll` runs before it opens, so an incomplete payload never starts one.
+Inside, the user insert and the consent insert both land or neither does. `pkg/db` has `WithTxn` but
+no context-carried transaction, so both repositories need a `Create` that takes a `*sqlx.Tx`. That
+is additive. If threading it through the user repository is rejected, the fallback is deleting the
+user row when the consent insert fails, and logging loudly if that delete also fails.
+
+### Errors
+
+Three errors alongside the existing block at `core/authenticate/service.go:53`:
+
+```go
+ErrLoginUserNotFound = errors.New("no account for this email")
+ErrSignupUserExists = errors.New("an account already exists for this email")
+ErrConsentRequired = errors.New("consent required for the configured documents")
+```
+
+They map to `CodeNotFound`, `CodeAlreadyExists` and `CodeFailedPrecondition`, returned from both
+`Authenticate` and `AuthCallback`. `AuthCallback` maps a fixed list to `InvalidArgument` and
+everything else to `Internal` (`internal/api/v1beta1connect/authenticate.go:117`), so all three must
+join that list or surface as 500s. `FailedPrecondition` is what lets a client separate a consent
+rejection from a bad code or an expired flow.
+
+A rejection ends the flow. Under a signup intent it happens at `Authenticate`, before an OTP or a
+redirect, so the user retries and loses nothing. Without an intent a consent rejection happens at
+user creation, and mail OTP needs a fresh code, since `applyMailOTP` calls `consumeFlow` before
+creating the user.
+
+### The other paths that create users
+
+`getOrCreateUser` has five callers, and two paths create a user row without going through it:
+
+| Path | With consent enabled |
+|---|---|
+| `applyOIDC` | gated: the flow carries the consent |
+| `applyMailOTP` | gated: the flow carries the consent |
+| `finishPassKeyRegisterMethod` | gated: the flow carries the consent |
+| `finishPassKeyLoginMethod` | gated: a first-time passkey login is a signup |
+| `authenticateWithPassthroughHeader` | not gated: no flow exists |
+| `organization.Service.AdminCreate` | not gated: operator action, no flow |
+| `CreateUser` RPC | not gated: operator action, no flow |
+
+The four flow-based paths are the ones people sign up through, and this RFC closes them. The other
+three are exempt, because no account holder is present to consent:
+`authenticateWithPassthroughHeader` provisions from `app.identity_proxy_header`, which already warns
+that it bypasses authorization, and the other two are operator actions. Exempt means no consent
+record, the same as users who already exist. Limitations records the gap.
+
+## Storage
+
+One consent record per consent, listing the documents it covers.
+
+```sql
+CREATE TABLE user_consents (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
+ user_id UUID NOT NULL,
+ user_email TEXT NOT NULL,
+ documents JSONB NOT NULL,
+ source TEXT NOT NULL DEFAULT 'signup',
+ auth_strategy TEXT,
+ ip_address TEXT,
+ consented_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT documents_not_empty CHECK (
+ jsonb_typeof(documents) = 'array' AND jsonb_array_length(documents) > 0
+ )
+);
+
+CREATE UNIQUE INDEX uq_user_consents_signup
+ ON user_consents(user_id) WHERE source = 'signup';
+```
+
+`documents` holds one object per accepted document, copied from config at write time, with the same
+four fields config holds:
+
+```json
+[
+ {
+ "id": "privacy_policy",
+ "title": "Privacy Policy",
+ "version": "2026-04-01",
+ "url": "https://example.org/legal/privacy/2026-04-01"
+ }
+]
+```
+
+The grain is the consent, not the document. A user accepts a set in one act, so `user_email`,
+`ip_address` and `consented_at` describe the act and are stored once, and the document list is an
+argument to the write rather than something the schema fixes. Alternative 3 has the tradeoff.
+`auth_strategy` holds the `strategy_name` the consent came through: `oidc`, `mailotp` or `passkey`,
+the flow's own word for it.
+
+No `metadata` column. Nothing would write it today, and a re-consent that needs one can add it in
+its own migration.
+
+Four choices are deliberate and look like mistakes otherwise.
+
+No foreign key to `users`. `UserRepository.Delete` does a hard `DELETE`, so `ON DELETE CASCADE`
+would drop the records and `ON DELETE RESTRICT` would block account deletion. Records must outlive
+the user, which is also why `user_email` is denormalized. `ip_address` is `TEXT` and nullable, not
+`INET`: the value comes from a request header, and a bad or missing one must not fail a signup.
+
+Versions and URLs are copies, not references, so a record stays readable years later and stays
+correct after the document leaves config. It also means a document table can be added later with no
+backfill.
+
+The unique index gives a user at most one signup consent. Nothing repairs a record, so a second
+signup write is a bug, and this makes it fail rather than leave two rows disagreeing.
+
+Records cannot be changed. `BEFORE UPDATE` and `BEFORE DELETE` triggers both raise `45000`,
+following `20250904105226_add_audit_records_immutability.up.sql`. That migration guards `UPDATE`
+only; `DELETE` is guarded here too, because a deleted record leaves a user who looks like they never
+consented. The repository has `Create` and nothing else. `DROP TABLE` still works, so `migrate down`
+is fine.
+
+A signup also writes one audit record. `pkg/auditrecord` gains
+`UserConsentGrantedEvent Event = "user.consent_granted"` and `ConsentType EntityType = "consent"`,
+following the `entity.verb` naming already there. Every field is set explicitly:
+
+| Field | Value |
+|---|---|
+| `Actor` | the new user: its id, `app/user`, email as name |
+| `Resource` | the same user |
+| `Target` | the consent record id, `consent` type, document ids and versions in `Metadata` |
+| `OccurredAt` | `consented_at` from the flow, not the write time |
+| `OrgID` | `schema.PlatformOrgID` |
+| `IdempotencyKey` | empty. It is nullable, and there is nothing to deduplicate |
+
+`Actor` cannot be left to enrichment. `AuditRecordRepository.Create` calls `enrichActorFromContext`
+when the actor is empty, and a skip-listed endpoint has no actor in context, so the record would
+land with `uuid.Nil` and the `system` actor for an act a person performed.
+`auditrecord.Service.Create` is worse: its `enrichUserActor` reads `Actor.ID` as a session id, and
+no session exists yet, so it returns `ErrActorNotFound`. So the write goes through the repository
+with the actor filled in, as `userpat` does for its PAT events.
+
+`OrgID` is `schema.PlatformOrgID`, the nil UUID, rather than empty. `user.Service` and
+`userpat.Service` already stamp it on the platform-level events they write, and it saves every
+reader special-casing a blank org.
+
+That write happens after the transaction commits, since `Create` has no `*sqlx.Tx` variant. It
+cannot be atomic with the consent record, which is why that record is the source of truth: if the
+audit write fails, log it and carry on.
+
+## Reading it back
+
+No read API and no view. A reporting tool reads `user_consents` as it is. A record is
+self-contained, so "what did this user accept, and when" is one row, found by `user_id`.
+
+There is no index on `documents`. The reverse question, "who accepted privacy policy 2026-04-01", is
+a containment filter, answerable as a scan:
+
+```sql
+SELECT user_email, consented_at, ip_address
+FROM user_consents
+WHERE documents @> '[{"id": "privacy_policy", "version": "2026-04-01"}]';
+```
+
+That is an occasional compliance query, not a request path, and a GIN index would be maintained on
+every insert to serve it. If it becomes a real access pattern, the index is one migration away.
+
+## Client
+
+Three files under `web/sdk/client/views/auth`, the only callers of `authenticate` in the repo.
+`sign-up/sign-up-view.tsx` sends signup on the OIDC buttons, `sign-in/sign-in-view.tsx` sends login,
+and `magic-link/magic-link-view.tsx` is shared by both, so it takes the intent as a prop.
+
+Neither view sends document ids, so with consent enabled the shipped sign-up view cannot complete a
+signup. `SignUpView` gains a `listConsentDocuments` query beside the `listAuthStrategies` one it
+already runs, and an Apsara `Checkbox` fed from the response:
+
+```tsx
+export type SignUpViewProps = /* ... */ & {
+ consentLabel?: ReactNode | ((documents: ConsentDocument[]) => ReactNode);
+};
+```
+
+An empty list means no checkbox, so a deployment with consent disabled sees today's view. With
+documents, the checkbox starts unchecked, every sign-up control stays disabled until it is checked,
+and the fetched ids go out as `accepted_document_ids`. `MagicLinkView` takes the ids alongside the
+intent, so both strategies use one control.
+
+The default label is built from the response: the copy around it is the SDK's, and each document
+contributes an Apsara `Link` to its `url`, titled with its `title`. Changing the documents in config
+changes the label, with no client release.
+
+`consentLabel` overrides it: a `ReactNode` for static copy, or a function of the documents for copy
+that links them. `ReactNode` does not admit functions, so one `typeof === 'function'` check tells
+the two apart, and both resolve to a node before the `Checkbox` sees it.
+
+A second checkbox, or one per document, means a consumer rendering its own view and calling
+`authenticate` directly, which already works.
+
+The ids the view sends are the ids the server just gave it, so a set mismatch takes a config change
+between the two calls. Nothing in the SDK hardcodes a document.
+
+`magicLinkHandler` handles only `status === 400` today and writes the message into the email field.
+It needs the three new codes, with copy pointing at the other view for the two gate errors.
+`config.redirectSignup` and `config.redirectLogin` already exist for the links.
+
+OIDC rejections arrive at `AuthCallback`, which has no error UI. Redirecting back to the originating
+view with an error param is the smaller change, rendering it in place is the other option, and this
+is undecided.
+
+## Alternatives considered
+
+1. Consent in `users.metadata`. Both update paths replace the whole map, so a user can delete their
+ own consent record, and it goes away with the user row.
+
+2. Consent in `audit_records`. `CreateAuditRecord` takes any event string with a client-supplied
+ `occurred_at`, gated on platform `check`, which both the admin and member relations grant, so any
+ platform member could insert a backdated consent record. A table with no write RPC can only be
+ written by the signup path.
+
+3. One row per accepted document instead of one per consent. It puts every field in a column, but it
+ repeats the email, IP and timestamp on every row, and it needs a synthetic event id to answer
+ "what did this user accept in one sitting". The act is what is recorded, so the act is the row.
+
+4. The documents in the database instead of config, as a table or a reconcile kind. Each record
+ already copies its document versions, so the records are the version history and a table answers
+ no query they cannot. Config sits in git, which is a better change log than rows an admin can
+ edit, and both need a write path, which is one more way to change what the server stamps.
+ `ListConsentDocuments` serves the list from config, so storage is a separate question. Future
+ work has the conditions for revisiting it.
+
+5. Taking consent in `AuthCallback`. The client would stash it locally and resend it after the
+ redirect, so the record would attest to a re-assertion made after the fact, the IP would be the
+ post-redirect one, and it would break when the provider returns into a different tab.
+
+6. Repairing a missing consent record on a later login. See Enforcement.
+
+7. Two RPCs, `Login` and `Signup`, instead of the intent. `AuthCallback` cannot be split alongside
+ them: `state` is the flow id, both gates run at callback time, and two callback URLs would have
+ to be registered with every provider. The intent would still ride on the flow, so the split
+ duplicates the entry point without moving either gate, and leaves `Authenticate` permanently
+ ungated. Frontier already discriminates strategies with `strategy_name` on one RPC.
+
+8. A `oneof` carrying a `LoginIntent` and a `SignupIntent` message, with the ids on the signup arm
+ only. It makes a signup-only field unrepresentable on a login rather than merely rejected, and
+ there is precedent in `ChangeSubscriptionRequest.Change`. But `AuthenticateRequest.email` is
+ already a field only some strategies use, checked at runtime, so the flat field is the shape this
+ message has. Moving later means deprecating field 6 and carrying both for a window, so adopt it
+ now if more signup-only fields are expected.
+
+9. Enforcing the login gate only at user creation, or failing at verification without sending
+ anything. Both keep `Authenticate` quiet about whether an address has an account. The first
+ wastes an OTP mail and puts "no account for this email" on the verification screen, where it
+ reads like a bad code. The second leaves the user waiting for a code that never arrives. The
+ clearer error was chosen over the quieter endpoint.
+
+10. A first-class `Intent` field on `Flow`. It types better than metadata and costs a migration on
+ `flows` for one string, when the consent payload has to go in `metadata` regardless.
+
+## Limitations
+
+Changing a document version needs a redeploy, since config is read at boot.
+
+Drift between what the user read and what the record says is narrowed, not closed.
+`ListConsentDocuments` and `Authenticate` are two calls, so a config change between them leaves a
+user who read version A with a record that says version B. The window is a page load rather than a
+release cycle. Ids catch a set mismatch; a version mismatch cannot be caught, since the client sends
+no versions.
+
+A record ties to a version string, not to the document text, so editing the file at a URL without
+bumping the version leaves every record for that version describing something the user did not see.
+A per-document hash would close this and can be added later.
+
+The IP is only as good as the header it comes from. A proxy that appends to `X-Forwarded-For`
+instead of overwriting it leaves the value under the caller's control, and a deployment that does
+not set the header gets records with no IP.
+
+Users who already exist get no consent record, and nothing will ever give them one, so coverage is
+never complete. Neither do users from the three exempt paths. "No account without consent" holds for
+the signup flow, not for every row in `users`.
+
+The unauthenticated `Authenticate` endpoint now answers whether an address has an account, for
+mailotp and passkey where the email is known at flow start. Rate limiting per address and per IP is
+the mitigation, not hiding the answer. Passkey already leaks existence through its response shape,
+and the intent neither widens nor closes that.
+
+The login gate is a UX boundary, not a security one. An unset intent keeps create-or-get, so any
+client can opt out by omitting the field. Consent cannot be opted out of that way, since
+`ResolveAll` runs at user creation under every intent.
+
+The user repository gains a create that takes a transaction. It is additive, but it is the one place
+this feature reaches outside its own domain.
+
+## Future work
+
+Re-consent when a document version changes. The write path already handles it: `Grant` takes the
+document list as an argument, and `source` separates one occasion from another. What is missing is
+enforcement, which has to move from user creation to a gate on authenticated requests, roughly what
+Keycloak's terms and conditions required action does.
+
+A server switch that rejects an unset intent, turning the login gate into a boundary. It needs a
+deprecation window first, since it breaks every client that has not moved.
+
+A per-document hash in config, copied into the record, tying a consent to the document text rather
+than to a version string someone typed into config.
+
+A `ConsentDocument` reconcile kind, if restarts become a problem. It needs list and write RPCs and a
+`consent_documents` table keyed by `(document_id, version)`, which the config map maps onto
+directly.
+
+Withdrawal, which needs a decision on what happens to the account.
+
+A reserved-event guard on `CreateAuditRecord`, so events frontier writes itself cannot be injected
+through the public RPC. Any platform member can forge a backdated `user.consent_granted` through it,
+the same way they can forge `pat.revoked`. That does not weaken `user_consents`, which has no write
+RPC, but it does mean the audit record is a breadcrumb and not evidence.
+
+## Work in order
+
+1. proton PR, one change: `FlowIntent`, `flow_intent = 6` and `accepted_document_ids = 7`, plus
+ `ListConsentDocuments` with its request, response and `ConsentDocument` messages.
+2. Bump `PROTON_COMMIT` in `Makefile:7` and run `make proto`.
+3. Core, the login gate first: the `FlowIntent` type, the request fields, the metadata write and the
+ nil-safe accessors, the `StartFlow` checks, the passkey branch, and the `getOrCreateUser`
+ signature.
+4. Core, consent: `app.consent` with boot validation, the consent service, the migration and
+ repository, and the transactional write in `getOrCreateUser`.
+5. Handlers: the intent and ids into `StartFlow`, `ExtractSessionMetadata` in `Authenticate`, the
+ three errors mapped in both `Authenticate` and `AuthCallback`, and the `ListConsentDocuments`
+ handler with its skip-list entry.
+6. SDK: the intent and ids through `MagicLinkView`, both views, the documents query, the checkbox,
+ and the error copy.
+7. Tests. Three intents against an address that does and does not have an account, at both
+ enforcement points, plus one case per strategy, since OIDC, mail OTP and both passkey methods
+ reach `getOrCreateUser` by different routes. Consent adds the complete, incomplete and
+ unknown-id sets, a consent insert failure rolling back the user row, and `ListConsentDocuments`
+ enabled and disabled.
+
+## References
+
+- [RFC 0001: Declarative management of platform resources](0001-declarative-reconcile.md), for the
+ reconcile flow mentioned above.
+- ISO/IEC 29184:2020, *Online privacy notices and consent*, and the Kantara Consent Receipt
+ specification, which the field list follows.
+- RFC 9126, *OAuth 2.0 Pushed Authorization Requests*, for keeping request data server-side behind
+ a handle.
+- Digital Personal Data Protection Act, 2023, section 6(1), for the "clear affirmative action"
+ standard.