ADR-0032: Single-Source Privacy Policy with CI-Generated Artifacts
DateFebruary 24, 2026
CategoryInfrastructure
Tagsci-cdprivacy-security
Context
- ADR-0030 defines three distribution channels for the privacy policy: in-app (bundled asset),
hosted URL (for Play Store listing), and the source repository (
docs/privacy-policy.md).
- Maintaining identical content across three locations is a known drift risk acknowledged in
ADR-0030's downsides: "Policy must be maintained across three locations (repo, in-app, hosted
URL)."
- Manual synchronization across locations is error-prone. A policy update that reaches the hosted
URL but not the in-app asset — or vice versa — creates a compliance gap and undermines user
trust.
- The project already runs a multi-stage GitLab CI pipeline (ADR-0022) that builds APKs, runs
validation, and distributes releases. Adding an artifact generation step fits naturally into this
infrastructure.
- The in-app policy must be accessible offline (ADR-0002, ADR-0005) and must not load remote
resources (ADR-0030 Notes).
- Markdown is the preferred authoring format: it is version-controlled, diff-friendly, and
familiar to the development workflow.
Constraints:
- One canonical source file, no manual copies.
- The app must bundle the policy without requiring a network call.
- The hosted version (HTML) must be deployable to a static site (GitHub Pages, GitLab Pages, or
similar).
- The build pipeline must fail if the policy file is missing or malformed, preventing a release
without a bundled policy.
Decision
We will maintain a single Markdown file as the canonical privacy policy source, and use the
CI pipeline to generate and distribute all derived formats.
1. Canonical source
docs/privacy-policy.md # Single source of truth, authored and reviewed here
This file is:
- Written in standard Markdown (CommonMark-compatible).
- Version-controlled in Git alongside the codebase.
- Reviewed via merge requests like any other code change.
- Prefixed with a YAML front-matter block containing metadata:
---
policy_version: "1.0"
effective_date: "2026-02-24"
---
# LocusFlow Privacy Policy
...
2. Derived artifacts
The CI pipeline produces two artifacts from the canonical source:
| Artifact | Format | Purpose | Destination |
|---|
privacy-policy.md | Markdown (copy) | Bundled in APK as a raw asset | app/src/main/assets/privacy-policy.md |
privacy-policy.html | Standalone HTML | Hosted at a public URL for Play Store | CI artifact / Pages deployment |
3. CI integration
A new generate-policy job runs in the Build stage of the existing GitLab CI pipeline
(ADR-0022), before APK compilation:
generate-policy:
stage: build
image: pandoc/minimal:latest # Lightweight image with pandoc pre-installed
rules:
- if: $CI_COMMIT_TAG && $CI_COMMIT_BRANCH == "main"
script:
# Validate the source file exists and has front-matter
- test -f docs/privacy-policy.md || (echo "ERROR: privacy-policy.md not found" && exit 1)
- head -1 docs/privacy-policy.md | grep -q "^---" || (echo "ERROR: missing front-matter" && exit 1)
# Copy Markdown to app assets (in-app rendering)
- mkdir -p app/src/main/assets
- cp docs/privacy-policy.md app/src/main/assets/privacy-policy.md
# Generate standalone HTML (for external hosting)
- mkdir -p public
- pandoc docs/privacy-policy.md
--from=markdown
--to=html5
--standalone
--metadata title="LocusFlow Privacy Policy"
-o public/privacy-policy.html
artifacts:
paths:
- app/src/main/assets/privacy-policy.md
- public/privacy-policy.html
expire_in: 30 days
Pipeline behavior:
- The
generate-policy job runs only on tagged commits on main (i.e., release builds). This
avoids unnecessary work on feature branches while ensuring every release includes the current
policy.
- The job uses a dedicated
pandoc/minimal Docker image rather than installing pandoc into the
Android SDK image, keeping the primary build image lean.
- The APK build jobs (
build-debug, build-release) depend on generate-policy via artifact
passing, ensuring the bundled asset is always up-to-date for release builds.
- If
docs/privacy-policy.md is missing or lacks front-matter, the pipeline fails, blocking
the release. No tagged build can ship without a policy.
- For non-tagged builds (development, MRs), the Markdown file in
docs/ is assumed to be present
but is not processed. Local builds can copy the file manually or via a Gradle task if needed
for development testing.
HTML artifact:
- The generated
public/privacy-policy.html is stored as a CI artifact (30-day retention) and
can be downloaded from any tagged pipeline.
- Deployment to a hosted URL (GitLab Pages, static hosting, or project website) is deferred to a
future phase. When needed, the HTML artifact from the release pipeline can be deployed manually
or via an additional CI job.
4. In-app rendering
The About screen reads the bundled Markdown asset and renders it using a Compose-compatible
Markdown renderer:
// ui/features/settings/AboutScreen.kt (sketch)
val policyMarkdown = remember {
context.assets.open("privacy-policy.md").bufferedReader().readText()
}
MarkdownText(markdown = policyMarkdown)
Options for Markdown rendering in Compose:
- Compose Markdown library (e.g.,
com.mikepenz:multiplatform-markdown-renderer) — preferred
for native Compose rendering without WebView.
- WebView with local HTML — fallback option if a richer rendering is needed. The HTML would be
generated at build time (same
pandoc step) and bundled as a second asset. The WebView must
load only local assets, no remote resources.
5. Change workflow
When the privacy policy needs to be updated:
- Edit
docs/privacy-policy.md in a feature branch.
- Update the
policy_version and effective_date in the YAML front-matter.
- Open a merge request. CI validates the file and generates preview artifacts.
- On the next tagged release from main, CI produces the final artifacts:
- The release APK build automatically includes the updated Markdown asset.
- The standalone HTML is available as a CI artifact for manual deployment.
- No manual copying, no separate deployment step for the in-app version.
6. Version tracking
The YAML front-matter provides two fields for the About screen:
policy_version: human-readable version string (e.g., "1.0", "1.1"), displayed in the
About screen alongside the app version.
effective_date: the date this version of the policy takes effect, displayed to users and
used for "last updated" labeling.
The app parses the front-matter at runtime (simple line-based parsing of the --- block) to
extract these values. No heavy YAML parser dependency is needed.
Rationale
- Single source over manual sync: The core risk this ADR mitigates is drift. With one file and
automated derivation, it is physically impossible for the in-app policy and the hosted policy to
diverge. The source is always
docs/privacy-policy.md; everything else is generated.
- Markdown as source format: Markdown is diff-friendly in Git, readable in raw form, easy to
author, and convertible to HTML. It matches the project's existing documentation format (all ADRs,
architecture docs, and guidelines are Markdown).
- CI generation over Gradle task: Using the CI pipeline with a dedicated
pandoc/minimal
Docker image keeps the Android build configuration and image clean. Pandoc is a standard,
well-maintained conversion tool. Alternatively, this could be a Gradle task using a Kotlin
Markdown library, but CI-level generation is simpler and doesn't add a build dependency.
- Dedicated image over installing pandoc in the Android image: The
pandoc/minimal image is
purpose-built, lightweight, and avoids bloating the Android SDK image with non-Android tooling.
The generate-policy job has no Android dependencies, so it does not need the Android SDK.
- Tag-only execution over every pipeline: Policy artifacts are only needed in release builds.
Running on every pipeline would add unnecessary job time to feature branch and MR pipelines.
The source file is always in the repo and can be validated via simpler means (e.g., a lint
rule) if early feedback is desired.
- Pipeline failure on missing policy: A release without a privacy policy is a compliance risk
and a Play Store rejection. Making the pipeline fail-fast on a missing or malformed policy file
turns this into an automated guardrail.
- Asset bundling over runtime download: The policy must be accessible offline (ADR-0002).
Bundling as an APK asset ensures zero network dependency and instant availability.
- Front-matter for metadata: Embedding
policy_version and effective_date in the Markdown
file keeps all policy-related information in one place. The About screen can display these values
without a separate configuration file.
Consequences
-
Positive:
- Drift eliminated: The in-app policy, hosted policy, and repo policy are always identical —
they all derive from the same file.
- Automated compliance: CI ensures every build includes the current policy. No manual
deployment step for the hosted version.
- Review workflow: Policy changes go through normal merge request review, providing an audit
trail in Git history.
- Lightweight: No new dependencies in the app beyond a Markdown renderer. No database
involvement. No server.
- Transparent versioning: Front-matter metadata provides clear "last updated" information
visible to both developers and users.
-
Downsides:
- The
generate-policy job requires a separate Docker image (pandoc/minimal). Mitigation:
this is a small, well-maintained public image; no custom image build is needed.
- Compose Markdown rendering may have limitations (complex tables, images). Mitigation: the
privacy policy is primarily text with simple tables; rendering requirements are modest.
- Front-matter parsing adds a small amount of code. Mitigation: the parsing logic is ~10 lines
of Kotlin string manipulation.
- Policy artifacts are only generated on tagged releases, so a policy update merged to main
does not produce artifacts until the next tag. Mitigation: if urgent, create a tag
specifically for the policy update, or run
pandoc locally.
-
Follow-up work:
- Draft the initial
docs/privacy-policy.md with front-matter.
- Add the
generate-policy job to .gitlab-ci.yml with artifact dependencies to the build
jobs, using the pandoc/minimal image.
- Choose and integrate a Compose Markdown rendering library.
- When a hosted URL is needed (Phase 10 / Play Store submission), decide on a hosting
strategy and optionally add a CI deployment job or document the manual upload process.
- Update ADR-0030 to reference this ADR for the implementation strategy.
Alternatives Considered
- Manual copy to three locations — Rejected: this is the exact drift risk we are mitigating.
Human discipline does not scale, even for a single developer.
- Gradle task instead of CI job — Viable but rejected: adds a build dependency (Markdown → HTML
converter) to the Android project. CI-level generation keeps the Android build lean. Could be
reconsidered if CI complexity becomes a concern.
- WebView loading a remote URL — Rejected: violates offline-only (ADR-0002, ADR-0005) and
ADR-0030's note that the in-app policy must not load remote resources.
- Storing the policy in Room or DataStore — Rejected: the policy is a static document, not
user data. An APK asset is the appropriate mechanism for bundled read-only content.
- Generating Markdown from HTML (HTML as source) — Rejected: HTML is harder to author and
review in Git diffs. Markdown → HTML is the natural direction.
- Separate repository for the policy — Rejected: adds coordination overhead. The policy is
tightly coupled to the app's data handling; it belongs in the same repo.
Notes
- If the project has a Compose Markdown dependency already being evaluated for other features
(e.g., rendering reflection summaries), reuse it for the privacy policy screen.
- The
pandoc command can be replaced with any Markdown-to-HTML tool (e.g., marked,
markdown-it, cmark). Pandoc is chosen for its robustness and --standalone HTML output,
but the pipeline is not locked to it.
- The front-matter block is stripped before rendering in-app. Compose Markdown libraries
typically ignore YAML front-matter, but if one doesn't, a simple
dropWhile on the lines
handles it.
- This ADR pairs with ADR-0030 (which defines what the policy says) and ADR-0022 (which defines
the CI pipeline this integrates into). Read together for the full picture.