dorfteich/docs/architecture/adr/0022-page-classification.md
Claude Fable 5 183faf7710
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m42s
CI / Build container images (pull_request) Successful in 3m56s
CI / Auth e2e pack (pull_request) Successful in 8m17s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 24s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 6m17s
CD / Smoke tests against Test (push) Successful in 3m32s
CI / Build container images (push) Has been skipped
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 8m20s
CI / Import/export fidelity gate (push) Successful in 55s
#204: classification as first-class page metadata (ADR 0022)
Enum field on Page (UNCLASSIFIED default, VS_NFD), migration backfills
existing pages. New pages take the instance-wide default from
classification.newPageDefault (admin-visible, de+en). The value rides in
every PageView, so no channel needs an extra request. The field is a
marking, not a protection mechanism: a test pins that permission
decisions are unchanged by it. The marking wording is fixed in ADR 0022
and sourced solely from classificationMarking() in @dorfteich/shared.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:01:50 +02:00

4.3 KiB
Raw Blame History

ADR 0022: Classification as first-class page metadata

  • Status: proposed
  • Date: 2026-07-29

Context

VS-NfD content must be marked, in every output that leaves the system. Dorfteich has no classification concept today: model Page carries title, slug, tree position and timestamps, and nothing else that could express a protection level.

The obvious shortcut is to reuse labels. Verification shows why that fails:

  • Label is pond-scoped (pondId), so the same classification would be a different object in every pond, with no instance-wide meaning.
  • Labels are user-editable by any editor; a marking must not be removable as a matter of routine content work.
  • Labels do not inherit down the page tree, so a subpage of classified content would silently be unmarked.
  • Labels never leave the application: export.service.ts loads labelIds only to feed permissions.filterPages, and no export path writes them out. A carrier that does not reach the output channels cannot serve as a marking.

The second question is architectural: should the application separate classification levels? It must not (ADR 0019, and the plan's Phase 0 guardrails). Separation is a platform property.

Decision

  1. A dedicated enum field on Page, with an instance-wide default from instance_settings. Not labels, for the four reasons above.
  2. Separation of levels happens outside the application: one instance per classification level. The application marks; it does not isolate. This is the central operational decision of the whole undertaking and belongs here rather than in a manual, because it defines what the feature is not.
  3. The application-side ACL is order, not a protection mechanism. Permissions keep working as they do (central, default-closed, deny-wins), and the classification field does not change them. Anyone reading the code must not mistake the field for an isolation boundary — the test in #204 pins that.
  4. Classification inherits down the page tree. A new or moved page takes at least its parent's level. Raising is ordinary editorial work; lowering requires a dedicated capability in the central permission model and is audited with old value, new value, actor and page.
  5. Every output channel carries the marking, and each is an independently closable issue: web view, browser print, server-side PDF, DOCX/ODT, Markdown ZIP, feeds, public API, search results, no-JS shell, attachment download. A channel that cannot carry it internally (arbitrary binary attachments) is marked externally — filename prefix plus companion file — and the remaining gap is a documented residual risk, not a silent one.
  6. Unclassified content shows no marking. Marking everything trains users to ignore markings.
  7. The marking wording is fixed and locale-independent: VS NUR FÜR DEN DIENSTGEBRAUCH — the official formula of the German VSA. It is deliberately not translated: a marking is a fixed legal formula, and a localized variant would not be the marking. Only the UI labels around it (e.g. an accessibility label naming the element) are i18n'd. The single source is classificationMarking() in @dorfteich/shared (packages/shared/src/pages.ts); no output channel hard-codes the string.

Consequences

  • Ten issues, because there are ten output paths; that is the honest cost of "in every output".
  • The no-JS shell and the SPA are separate render paths, so each needs its own assertion. Likewise the TipTap NodeView path and the server-side docToHtml path differ structurally.
  • The field is a precondition for the read-access audit trail (ADR 0023), which is scoped to classified content only.
  • Attachments inherit their page's classification. The case where the page link is not yet set (paste-then-insert) fails closed.
  • Because levels are separated by instance, a page can never "move between levels" inside one deployment — export/import across instances is the path, and its marking is covered by the export channels.

Implementing issues

#204 (field + default), #205 (inheritance + downgrade right), #206 (web), #207 (print), #208 (PDF), #209 (DOCX/ODT), #210 (Markdown ZIP), #211 (feeds/API/search/no-JS), #212 (attachments), #213 (upload warning).