All Decisions

ADR-0032: Single-Source Privacy Policy with CI-Generated Artifacts

DateFebruary 24, 2026
CategoryInfrastructure
Tags
ci-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:

ArtifactFormatPurposeDestination
privacy-policy.mdMarkdown (copy)Bundled in APK as a raw assetapp/src/main/assets/privacy-policy.md
privacy-policy.htmlStandalone HTMLHosted at a public URL for Play StoreCI 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:

  1. Edit docs/privacy-policy.md in a feature branch.
  2. Update the policy_version and effective_date in the YAML front-matter.
  3. Open a merge request. CI validates the file and generates preview artifacts.
  4. 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.
  5. 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.