Short answer: evaluate consent categories at the moment a logistics login decision is made, and expose a separate list view for changing preferences; never infer an allow decision from a display list. For phone one-time-code login, keep account recovery available only when its category is explicitly allowed, record the policy version, and make a denied path predictable.
A consent banner is not an authorization system.
Start with the deny path.
The operational failure I see most often is a UI list becoming the source of truth: a driver unchecks analytics, the list is cached, and a later login request accidentally treats the cached list as permission for security messages. Recovery then depends on a stale browser state. That is an incident-shaped design, even if every individual function passes its unit tests.
What should runtime consent enforcement decide during a phone login?
Separate three things: category decisions, preference presentation, and recovery eligibility. A category decision is a small, auditable answer such as security_otp = allow; a preference view is a projection for a person to inspect and edit; recovery eligibility is a policy evaluation that can combine account state, channel ownership, and consent. These objects can share an identifier, but they should not share write paths.
For a logistics app, security notifications and optional product measurement are different categories. The first may be required to deliver a one-time code, while the second should not be smuggled into the same SMS. If a user revokes measurement consent, the next OTP still follows the security policy, but no measurement event should be emitted. Your mileage may vary when local privacy law defines different lawful bases, so have counsel map the categories before engineers freeze their names.
I use an immutable decision record with a short expiry for the evaluation result. It carries subject, category, decision, policy version, and reason. A preference list is generated from current policy metadata and the subject's latest decisions. It is not accepted as an authorization token.
A small Go policy boundary
The boundary below is deliberately boring. It keeps category checks in one place, makes the denied branch explicit, and gives observability enough dimensions for an SLO without logging the phone number or code.
package consent
import (
"context"
"errors"
"time"
)
type Decision string
const (
Allow Decision = "allow"
Deny Decision = "deny"
)
type Record struct {
Subject string
Category string
Decision Decision
PolicyVersion string
Reason string
ExpiresAt time.Time
}
type Store interface {
Latest(context.Context, string, string) (Record, error)
}
var ErrConsentDenied = errors.New("consent denied")
func Check(ctx context.Context, store Store, subject, category string, now time.Time) (Record, error) {
record, err := store.Latest(ctx, subject, category)
if err != nil {
return Record{}, err
}
if record.Decision != Allow || !record.ExpiresAt.After(now) {
return record, ErrConsentDenied
}
return record, nil
}
The OTP handler calls Check for the security category before sending a code. It does not call a preference-list endpoint and it does not accept a category supplied by the client without validating it against server policy. A denial should produce the same user-facing recovery guidance every time, while metrics distinguish denied, expired, and store_error; that distinction is useful during an on-call shift and does not leak a sensitive reason to an attacker.
How do category checks and preference lists survive deploys?
Treat policy as versioned data. A deploy that renames security_otp or changes its default is a schema migration, not a copy edit. During rollout, accept the previous and new policy versions for reads, write only the new version, and publish a counter for mixed-version evaluations. Set an SLO for decision latency and for the percentage of login attempts with a durable decision record; a fast check that cannot be reconstructed later is not operationally useful.
Verification belongs in the runbook.
Keep the test data synthetic.
Exercise an allowed OTP, an expired decision, a revoked decision, and a store timeout. Confirm that no optional event is emitted in the revoked case, that retries do not create duplicate recovery messages, and that logs contain a decision id rather than a phone number. Test the preference view separately: editing a list must create decisions, and rendering a list must never mutate them.
Capacity planning matters because OTP traffic arrives in bursts at shift changes. Size the decision store for peak login attempts plus replay-safe retries, then load-test the slowest dependency with the same timeout budget used in production. I once started with a generous five-second dependency timeout; a queue of drivers waiting at a depot made that look harmless until the login SLO was measured end to end. I had treated the dependency timeout as a vendor detail, but it was actually the user-visible recovery budget, and retries multiplied the queue while the first request was still waiting. The useful limit was lower, with a clear fallback message, bounded retries, and a staffed recovery channel. I am still not sure one timeout fits every depot, so the runbook records the measured SLO and the assumption behind it.
Buy, build, or split the boundary?
| Approach | Strength | Trade-off | Best fit |
|---|---|---|---|
| Build checks in the application | Lowest moving parts and direct domain context | Every service must reproduce policy and audit behavior | One service with a small policy surface |
| Run a dedicated policy component | Central audit trail and consistent category semantics | Adds a network hop and another SLO to own | Several services sharing consent decisions |
| Buy a managed identity layer, keep consent local | Faster phone challenge delivery | Recovery and consent data cross a vendor boundary | Teams with limited on-call capacity and clear data contracts |
The catch is that a dedicated or managed component is not suitable when the team cannot operate its failure mode or explain where recovery data lives. Stick with an in-process boundary when an outage would otherwise strand drivers and there is no tested manual recovery path. Conversely, split the boundary when independent services keep inventing category names; consistency is worth the extra hop only after its timeout and rollback behavior are measurable.
Rollback is a policy operation. Keep the previous policy version readable, flip a feature flag to stop new writes, and replay a sample of decisions against both versions before removing the old one. Never roll back by deleting records: deletion destroys the evidence needed to explain why a code was sent.
A practical decision rule is simple: if a category controls a security or recovery action, enforce it at the call site with an auditable check; if it only helps a person understand settings, serve a list projection. Keep those paths distinct, measure them separately, and make the denied path as well-tested as the happy path.
Top comments (0)