Open one user record in production and read the age fields out loud.
The operating system says the account holder is in the 16 to 17 bracket. Your app store integration says 18 and over. The profile form says a date of birth in 1994. A facial estimate from last Tuesday returned 21 with a wide confidence interval. A document check from eighteen months ago returned over 18 and then the images were deleted, as they should have been.
Five answers. Two of them cannot both be true.
Now the question that matters, and it is an engineering question rather than a legal one: what does your code do right now, today, in that situation? Not what should it do. What does it do?
For most teams the honest answer is that the field holds whichever value was written last, and the order of writes depends on which webhook arrived first.
The number of sources went up sharply, and nobody re-opened the data model
Six weeks ago this was a smaller problem.
Microsoft began shipping age APIs to Windows Insiders in September 2026. Those calls return a bracket and, unusually, a statement of how the age was produced: verified, declared, or declined. Australia’s app store age checks went live on 21 September 2026, which put a store-level age signal in front of developers whether they asked for one or not. Around 30 September Xbox sent a second wave of emails telling users it had estimated their profile to be an adult account from account history and set the verification status to “Verified”. California’s AB 1043 takes effect on 1 January 2027 and makes the operating system band the primary indicator of a user’s age.
Count the sources a single signed-in user can now present:
| Source | What it gives you | Category |
|---|---|---|
| Operating system age API | A band, plus provenance on Windows | Declared or verified, depends |
| App store signal | A declared range, sometimes parent-shared | Declaration |
| Government or bank wallet credential | A threshold answer, cryptographically signed | Verification |
| Document check you ran | A threshold answer plus a confidence | Verification |
| Facial age estimation | An estimate and an interval | Estimation |
| Profile date of birth | Whatever the user typed | Declaration |
| Platform inference, such as account tenure | A guess your own system made | Inference |
ISO/IEC 27566-1 splits age assurance methods into three families: verification, estimation and inference. Seven rows, three families, one database column called something like verified_age or is_adult.
That column was designed when there was one source. There are now up to seven, they arrive at different times, they expire at different rates, and three of them did not exist in your integration six weeks ago.
The waterfall answers a different question
We have argued at length that one method is not a system, and that the right shape is a waterfall: cheap and low friction at the top, assured underneath, escalating when the first attempt is not good enough.
That architecture answers one question well. What do we try next when a method fails or comes back ambiguous?
It does not answer this one. What do we do when two methods both succeed, and give different answers?
The two problems feel related and are not. Fallback is sequential, and it stops as soon as something works. Conflict is simultaneous, and the thing that works is the problem, because two things worked. A waterfall with no conflict rule just means the last stage to return gets to decide, which is the same accident as before with more steps in front of it.
You already have a precedence rule. Nobody chose it.
“Last write wins” is a precedence policy. It is a policy about evidence, expressed as an accident of queue ordering.
Three ways it bites in practice.
Out-of-order delivery. A vendor webhook for a check completed at 09:14 is retried after a transient failure and lands at 11:40, after a fresher signal from 10:05. The older evidence overwrites the newer evidence, because your write path orders by arrival and not by the time the decision was made.
The cache that outlives the truth. Google’s practice of refreshing a cached age signal some weeks after a user’s birthday is sensible on its own terms. It also means a store signal you read today can describe a person as they were last month. Two sources disagreeing by one band, where one of them is simply behind, is not a conflict about the person. It is a conflict about time, and treating it as a conflict about assurance gets you the wrong answer.
The service that writes without knowing. Six services can write to a user record. One of them is a support tool where an agent corrects a date of birth by hand. Another is a migration script. Neither of those knows what the age field means to the gate that reads it downstream.
None of these are exotic failures. They are the normal behaviour of a distributed system where a field has several writers and no owner.
Three rules that sound right and are not
Before the rule that works, the three that teams reach for first.
Most restrictive wins. Take the youngest answer every time. It sounds responsible and it has an obvious appeal: you never wrongly let a child through on a technicality.
It also means any single weak signal can demote a verified adult. A stale store band, a facial estimate at the low end of its interval, a shared household device, a mistyped form field. Each of those now outranks a document check. You have built the adult lockout problem into your precedence layer, where it will be hard to find, because the gate is working exactly as written. A 34 year old with a passport on file gets blocked by a guess, and your support queue finds out before you do.
Highest assurance wins. Rank the methods and let the strongest one decide. Document beats wallet beats estimate beats declaration.
The flaw is that assurance is not the only axis. A document check from two years ago is high assurance about a person at a point in time. It says nothing about who is holding the session tonight, which is the whole reason binding a check to the actual user is its own problem. It also says nothing about the user’s age today, which is why an age check with no expiry date is a liability rather than an asset. Rank purely by method and a strong old signal silently beats a weak fresh one, forever.
Worse, the ranking is only as honest as the provenance. The Xbox case is the clean example: a tenure-based inference was written into a field whose value reads “Verified”. A precedence rule that sorts on the label rather than the method will rank that above a live facial estimate. That label is a claim, not a fact, and your sort function cannot tell the difference.
Freshest wins. Newest signal decides. Clean, easy to reason about, trivially gamed.
This is retry abuse with a nicer name. If the most recent answer always overwrites the previous one, the user has an unlimited number of attempts to produce the answer they want, and the cheapest method is usually the one they can repeat. Freshness is a necessary input. On its own it is a bypass.
The rule that works: the direction decides the burden
Here is the part worth taking away.
Stop asking “which signal is strongest”. Ask “which way is this signal trying to move the user”, and set the evidence bar by direction.
Two directions, and they are not symmetric.
Promotion. A signal wants to move the user into an older band, unlocking something. Adult content, a chat feature, a purchase, a less restricted feed.
Restriction. A signal wants to move the user into a younger band, locking something down.
Now price the mistakes.
A wrong restriction blocks a real adult. It is annoying, it costs you conversion, and it is loud. The user complains, and there is an appeal path with a human at the end of it, and the mistake gets corrected within hours. It generates evidence. You can count it.
A wrong promotion hands an adult experience to a child. Nobody complains. The minor got the outcome they wanted. The parent does not know the field exists. It generates no evidence at all, so you cannot count it, and it does not get corrected because nobody asks.
Those two errors are not equally bad, not equally visible, and not equally recoverable. So they should not require the same quality of evidence.
| Promotion (unlock) | Restriction (lock down) | |
|---|---|---|
| Evidence required | High assurance, bound to this session, fresh | Any credible signal |
| Minimum method family | Verification, or estimation well clear of the threshold | Declaration or inference is enough |
| Freshness requirement | Within your re-verification window | Not required |
| Reversible by the user | Through step-up verification | Through appeal, with a human |
| Error if you get it wrong | Silent, uncounted, uncorrected | Loud, counted, corrected |
Read the last row again. That asymmetry in the evidence you leave behind is the whole reason the evidence bar has to be asymmetric too.
Said as one sentence: it should be easy for a signal to restrict a user and hard for a signal to unlock one.
This also resolves the three broken rules. Most-restrictive-wins is correct for the restriction direction and wrong for promotion. Highest-assurance-wins is correct for promotion and far too strict for restriction. Freshest-wins is a tiebreak inside a direction, never a rule across directions.
Why the penalty structure agrees with this
The asymmetry is not only an engineering preference. It matches your exposure.
AB 1043 prices violations at up to $2,500 per affected child for negligent conduct and up to $7,500 per affected child for intentional conduct. Per affected child. There is no corresponding per-inconvenienced-adult figure anywhere in the statute. The two error directions have wildly different prices, and the one with the price attached is the one your metrics cannot see.
To be careful about what the law says: AB 1043 does not contain a directional precedence rule. What it contains is an override. The operating system band governs unless you hold clear and convincing information that contradicts it. That override is agnostic about direction on its face.
But notice what the override actually does. It makes your own knowledge the trigger. The moment your systems hold a credible contradictory signal, the store signal stops protecting you. So a platform that holds a weak younger signal and keeps serving the stronger older one has the worst of both: it has the knowledge, so the safe harbour is gone, and it acted on the direction that carries the penalty.
Ofcom’s four criteria point the same way without saying so. A method has to be technically accurate, robust, reliable and fair. Robust means resistant to circumvention. A promotion rule that any cheap signal can satisfy is not robust, by definition, and the circumvention is free.
A precedence policy you can write down
The point of all of the above is that this becomes a function with a name, in one place, with tests.
type Family = 'verification' | 'estimation' | 'inference' | 'declaration';
type AgeClaim = {
source: string; // 'windows-age-api', 'doc-check', 'profile-form'
family: Family;
band: AgeBand; // under13 | 13to15 | 16to17 | adult, not a boolean
issuedAt: Date; // when the DECISION was made, not when it arrived
boundToSession: boolean;
confidence?: number;
};
// The only function allowed to decide. One place, one owner, tested.
function resolve(claims: AgeClaim[], current: AgeBand, policy: Policy): Resolution {
const fresh = claims.filter(c => !isStale(c, policy));
// 1. Restriction is cheap to act on. Any credible fresh claim that points
// younger than the current band wins immediately.
const younger = fresh.filter(c => c.band < current);
if (younger.length > 0) {
return restrict(lowest(younger), 'younger-claim-present');
}
// 2. Promotion is expensive. It needs a claim that clears the assurance bar,
// is bound to this session, and is inside the freshness window.
const promoting = fresh.filter(c =>
c.band > current &&
meetsAssuranceBar(c, policy) &&
c.boundToSession
);
if (promoting.length === 0) {
// Hold the current band and offer step-up. Do NOT silently promote.
return hold(current, 'no-qualifying-promotion-claim');
}
// 3. Within one direction, freshness is the tiebreak. Across directions it is not.
return promote(mostRecent(promoting), 'qualifying-promotion-claim');
}
Four properties of that shape are worth stating on their own, because they are what the tests pin down.
issuedAt is the decision time, never the receipt time. That one line fixes the out-of-order webhook.
The comparison is between a claim and the current band, not between claims. Direction only exists relative to where the user is now.
A failed promotion returns hold, not deny. The user keeps the band they had and gets offered a step-up route. There is no state where failing to prove you are older makes you younger.
And resolve is the only function permitted to write the band. Every other service submits a claim.
Three conflicts, and what the rule does with each
The stale store signal. Operating system says 16 to 17. Your document check from four months ago says adult. The user is in fact 19 and had a birthday.
Direction: the store signal would restrict. Rule says restriction acts on any credible claim, so the user gets restricted. That feels wrong, and it is still the right call, because of what happens next: the restriction is visible, the user complains within minutes, the appeal path resolves it, and your step-up route re-promotes them on a fresh bound check. Total cost: one annoyed adult for one session, and a log entry that tells you your store signal is running behind. The alternative is a rule that lets a four month old document outrank a live signal, which is the rule that passes minors.
Instrument this one. A rising count of restrictions later reversed on appeal is not noise. It is your freshness window being wrong, and it is one of the few numbers in age assurance that actually measures something.
The tenure promotion. Your own inference, from account history, says adult. The app store band says 13 to 15.
Direction: inference wants to promote. It is an inference, it is not bound to the session, and it did not clear the assurance bar. So it does not promote. The user holds at 13 to 15 and the inference is recorded as a claim rather than a decision. Under the old last-write-wins behaviour this user is an adult tonight, and you will never hear about it.
Declined is not a direction. Windows returns declined, because the user refused to verify.
This is the trap we flagged when the API appeared. declined is not a younger claim and not an older one. It carries no band at all, so it never enters resolve. It routes to your own verification path with an explanation. A precedence function that coerces a missing value into a band, in either direction, is wrong in both.
Four traps in the plumbing
Order by issuance, verify by signature. issuedAt from a vendor is only trustworthy if the claim is signed or the channel is. Otherwise a replayed webhook with an old timestamp is a downgrade attack, and one with a future timestamp pins the field permanently.
Give the band one writer. If the support tool, the migration script and three services can all write the band directly, you do not have a precedence policy, you have six of them. Migrate those paths to submitting claims. The support correction is a claim with a human source and high trust, which is fine. It is just a claim.
Make restriction genuinely reversible. The whole argument for a low bar on restriction rests on the appeal route working. If your appeal path is a contact form nobody reads, then the cheap restriction is not cheap, it is a permanent wrongful block, and the asymmetry collapses. Build the step-up route before you lower the restriction bar, not after.
Keep the claims. Do not collapse them. This is the one that decides whether you can answer a regulator later. A single resolved integer cannot tell anyone which signal you acted on, what the alternatives said, or which rule version was running. Ofcom has already fined a platform over the evidence it could not produce, and “we store the resolved age” is not an answer to “why did you treat this account as an adult in September”.
What to store
Not an age. A set of claims and a resolution over them.
| Field | Why it is there |
|---|---|
band |
The resolved band. Bands, not a boolean, because the law now has four states and one of them is not an age |
claims[] |
Every claim received, with source, family, band, issuedAt and binding |
decided_by |
Which claim won |
decision_reason |
Which branch of the rule fired |
direction |
Promotion, restriction or hold |
policy_version |
So a rule change is visible after the fact |
assurance_level |
The family and method of the winning claim, never the label a vendor printed |
expires_at |
Derived from the winning claim’s family, not a global constant |
Then “why is this account an adult” is a query, and “what would have happened under last month’s rule” is also a query. Both of those questions get asked eventually, usually by someone with a deadline.
How Xident fits
Assurance level is a first-class field, not a comment. Every Xident decision records method, outcome, threshold, confidence, policy version and timestamp. A precedence rule needs the family and the method of each claim to sort honestly, and that is exactly what the record carries. Sorting on a vendor’s label is how an inference gets ranked above a document check.
Results come back as threshold classifications. Over or under 12, 15, 18, 21 or 25, with a configurable challenge-age buffer per surface. A threshold answer is directly comparable to a band, which is what resolve needs. A raw estimate is not.
Step-up is a route, not a rebuild. The hold branch above is useless without somewhere to send the user. Escalation from a browser Check to document verification and face match is part of the same integration, so a failed promotion has an answer in the same session.
Policy versions are recorded. When you move the freshness window or raise the promotion bar, records written before and after say which rule was running. That is what lets you explain September in October.
Records survive, images do not. Document images are deleted within 24 hours of processing. What remains is the decision, which is the part a precedence audit needs and the part that does not become a breach disclosure later.
The free sandbox grants 1,000 Checks and 100 document Verifications as one-time allowances, which is enough to replay a sample of your real conflicts through a rule before you commit to it. Rates are on the pricing page.
The short version
Your users have several ages on file. In the last six weeks they acquired more of them, and nothing about your schema changed.
Something in your code already picks a winner. For most teams that something is the order the webhooks arrived in, which is a policy about evidence chosen by a message queue.
The three obvious rules all fail. Most restrictive locks out verified adults. Highest assurance lets a two year old document beat a live check. Freshest is retry abuse with better branding.
The rule that holds is directional. Make it easy for a signal to restrict a user and hard for a signal to unlock one, because a wrong restriction is loud, counted and fixable within the hour, and a wrong promotion is silent, uncounted and permanent.
Then write it down as one function, with one owner, with tests, and keep every claim you threw away. The question is not which signal you trust. It is which direction the signal is pushing, and whether you can prove afterwards why you let it.
This post is general information about age-assurance architecture and public policy, not legal advice. Regulatory expectations and guidance differ by jurisdiction and continue to evolve; consult qualified counsel for your specific obligations.