A localized terms and conditions app flow can display the right language and still record the wrong agreement. The screen may load a mutable web page, the consent table may store only accepted = true, or a new source version may go live before every translation is approved. When someone later asks what the user saw, the system cannot answer. Fix this by treating each legal document as an immutable, versioned release artifact. Resolve language and jurisdiction separately, activate only approved variants, and store the exact document identity with every consent event.
Why translated legal screens lose their history
Legal copy often sits outside the app's normal localization pipeline. A product team may keep buttons and labels in string catalogs while serving terms or privacy disclosures from a CMS, hosted page, or remote configuration. One practitioner described switching between separate Terms web pages based on the app locale. That solves presentation for one implementation, but a URL alone does not prove which revision was served.
The artifact also crosses product surfaces. Apple's App Review Guidelines require a privacy-policy link in App Store Connect metadata and within the app. The store link, in-app link, consent screen, and backend record can drift if each points to a mutable page or follows different locale rules.
Four common shortcuts create the gap:
- A Boolean consent field records acceptance without a document version.
- A single URL is overwritten when the source or translation changes.
- Language is inferred from the device even when the user selected another app language.
- Locale and legal jurisdiction are treated as one value, although language preference does not establish which regional document applies.
An audit-ready design needs durable identities and explicit resolution rules before it needs another translated page.
Model every legal document as an immutable release
Give each published variant an identity that never changes after activation. Editing a published page should create a new revision, not mutate the prior record. Keep the source version shared across translations so the system can prove which localized variants represent the same approved meaning.
A practical record can look like this:
{
"document_id": "privacy-policy",
"source_version": "2026-08-27.1",
"locale": "fr-FR",
"jurisdiction": "EU",
"content_revision": 3,
"status": "approved",
"effective_at": "2026-09-10T00:00:00Z",
"content_url": "/legal/privacy/2026-08-27.1/fr-FR/eu",
"content_sha256": "<sha256-of-rendered-document>",
"source_sha256": "<sha256-of-approved-source>",
"approved_by": "legal-review-ticket-1842"
}
source_version groups translations of one approved source. content_revision permits a corrected translation to receive a new immutable identity without pretending the source meaning changed. effective_at keeps approval separate from activation. The two digests let a verifier compare stored metadata with the bytes actually delivered.
Do not put mutable reviewer names, comments, or workflow state into the document body. Store that information in an approval log keyed to the document identity. The published artifact remains stable while the review trail can record who checked terminology, legal meaning, layout, and release readiness.
Resolve language and jurisdiction separately
Use the app's effective locale as the first language input when the user chose it explicitly. Browser or device language is a fallback, not an override. HTTP defines Accept-Language as a preference signal, and RFC 9110 warns that it may not represent the user's intent. That warning applies when an app opens a hosted document in a browser whose language differs from the app selection.
Jurisdiction needs its own input. Depending on the product and advice from counsel, it may come from an account contract, verified residence, store territory, or another explicit business rule. Do not derive it from fr-FR or en-US inside the localization layer. A French-language reader may need a Canadian, French, Belgian, or general document.
Write one resolver with visible precedence:
resolveDocument(documentType, userContext):
locale = userContext.explicitAppLocale
?? userContext.accountLocale
?? userContext.deviceLocale
?? configuredDefaultLocale
jurisdiction = resolveJurisdictionFromBusinessRules(userContext)
candidates = approvedDocuments(
documentType,
activeAt = now,
jurisdiction = jurisdiction
)
return exactLocale(candidates, locale)
?? approvedLanguageFallback(candidates, locale.language)
?? approvedDefault(candidates)
?? failClosed("No approved document variant")
A fallback is safe only when the compliance owner has approved it for that document and jurisdiction. The resolver should never pick the newest database row or any available language. If no allowed variant exists, block the consent action and show a localized operational error with a support path. That is easier to diagnose than silently collecting acceptance for an unintended document.
When the app renders hosted HTML, declare the actual document language. W3C explains how the HTML lang attribute supplies the processing language for a page. Set it from the resolved artifact, not from a template default. Also expose the document language and effective date in visible copy so support can confirm the selection without inspecting network traffic.
Build an approval and activation workflow
Treat legal localization as a release pipeline with separate states. A useful state sequence is draft, translated, linguistically_reviewed, legally_approved, scheduled, active, and retired. Each transition should append an event rather than rewrite history.
Use this release sequence:
- Freeze the approved source and assign a new
source_version. - Identify required locale and jurisdiction combinations from the current product distribution matrix.
- Translate each required variant with the source digest and version attached to the job.
- Run structural checks for links, headings, variables, and required clauses.
- Record linguistic and legal approval independently.
- Render immutable pages and calculate content digests from the rendered output.
- Verify that the planned activation set contains every required approved variant.
- Activate the set atomically or hold the release.
- Test store, app, and hosted-document links against the active identities.
Atomic activation prevents a new English source from replacing the old version while French and Japanese still point to prior meaning. If the storage system cannot activate a complete set in one transaction, publish a release manifest first. Clients resolve only versions named by the active manifest.
The localization system should reject translations whose stored source_sha256 no longer matches the frozen source. This catches a late source edit after translation started. It also distinguishes a wording correction from an approved source change that requires a fresh translation and review cycle.
Store an immutable consent receipt
A consent event should identify the document, not copy only the current URL. Store enough data to reconstruct the decision without depending on mutable content tables.
{
"consent_event_id": "evt_01K4EXAMPLE",
"subject_id": "user_4821",
"action": "accepted",
"document_id": "privacy-policy",
"source_version": "2026-08-27.1",
"content_revision": 3,
"locale": "fr-FR",
"jurisdiction": "EU",
"content_sha256": "<sha256-of-rendered-document>",
"accepted_at": "2026-09-12T14:08:31Z",
"surface": "ios-app",
"app_version": "8.4.0"
}
Keep this event append-only. Withdrawal creates another event. A renewed acceptance creates another event. Do not update the original row to represent the latest state because that destroys the sequence needed to explain what happened.
Store the rendered document or retain it in immutable object storage under the versioned identity. A digest without the original bytes can prove that two copies match, but it cannot recover content that was deleted. Set retention and access rules with the compliance and security teams because these records may contain account identifiers and legal evidence.
Decide when a change requires re-consent
Engineering should encode the decision made by legal or compliance, not decide legal materiality from a text diff. Add a release field such as consent_policy with explicit values: no_action, notify, or require_acceptance. Require the approver and decision record before activation.
A translation typo may create a new content_revision under the same source version. A material source change should create a new source_version. Whether either event requires acceptance depends on the documented decision for that release. The app then follows data instead of guessing from version numbers.
At sign-in or before a protected action, compare the user's latest accepted identity with the active manifest:
if active.consentPolicy == "require_acceptance"
and receipt.sourceVersion != active.sourceVersion:
present(active.resolvedDocument)
else if active.consentPolicy == "notify"
and receipt.sourceVersion != active.sourceVersion:
showChangeNotice(active.resolvedDocument)
Never compare only the locale. A user can keep the same language while the source version changes. If the user later changes the app language, do not invent a new acceptance unless the documented policy requires it. Keep the original receipt and allow access to the accepted version plus the current translation.
Handle failures without collecting ambiguous consent
Fail before the acceptance button becomes active when the document cannot be resolved or verified. Record an operational event with the requested locale, jurisdiction, manifest version, and failure code, but do not write a consent receipt.
Cache immutable documents by full identity rather than by privacy-policy alone. A cache key should include source version, content revision, locale, and jurisdiction. Verify the digest after download and before display. Offline behavior must be explicit: either permit a previously verified active artifact under a defined policy or require connectivity. Do not fall back to whatever legal page happens to be cached.
Keep the acceptance request idempotent. A retry caused by a weak connection may submit the same event twice. Use an idempotency key tied to the displayed document identity and interaction, then return the existing receipt when the same request arrives again.
Verify the complete localized consent path
Test the delivered transaction rather than individual translation files. Build fixtures for at least one exact locale, one approved language fallback, one missing required variant, one jurisdiction change, one source-version change, and one translation-only revision.
For each fixture, assert that:
- the resolver returns the expected immutable identity;
- the page URL, visible locale label, HTML
lang, and content digest agree; - the acceptance payload repeats that identity exactly;
- the stored receipt matches the payload and retained artifact;
- a material-change policy triggers the configured action;
- a missing or unapproved variant prevents acceptance;
- store and in-app privacy links reach the intended active release;
- previously accepted artifacts remain retrievable after a new release.
Run one device test where the app language differs from the operating system and browser language. That catches the boundary most unit tests miss. Then change the jurisdiction input without changing language and verify that the resolver selects a different approved artifact.
Start by querying the current consent table. If it cannot answer which source version, locale, jurisdiction, and rendered content a user accepted, freeze the existing documents under explicit legacy identities before the next legal-copy release. Add the versioned resolver and receipt schema first, then move translations into the new approval pipeline.
References
- Apple App Review Guidelines, privacy supports the requirement to expose a privacy-policy link in storefront metadata and within the app.
- RFC 9110, Accept-Language supports the limits and semantics of HTTP language preference negotiation.
- W3C, Declaring language in HTML supports setting the processing language on hosted legal documents.
- Stack Overflow API, localized Terms web view records a practitioner question about selecting separate Terms URLs by locale.
