dorfteich/docs/architecture/adr/0022-page-classification.md
Claude Fable 5 e505fc74dc
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m34s
CI / Build container images (pull_request) Successful in 14s
CI / Auth e2e pack (pull_request) Successful in 9m36s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#212: mark attachment downloads by filename prefix and companion file
Downloads whose effective classification is vs_nfd carry the documented
VS-NfD_ filename prefix (single source classificationFilenamePrefix() in
shared; ADR 0022 records the short form for file names). Effective
classification: the linked page's level; an attachment with unset pageId
(paste-then-insert, pond-level) FAILS CLOSED to the highest level of any
live page in its pond. The pond export ZIP adds a sibling
<file>.classification.txt companion with the full marking for classified
media, next to the manifest entry (#210). Documented in operations.md,
incl. the deliberate residual risk: the file's own content carries no
marking (recorded on #231, not hidden). Tests: prefixed classified
download, unchanged open download, fail-closed orphan both ways, ZIP
companion + manifest level.

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

4.5 KiB
Raw Permalink 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. Where a full wording cannot live — file NAMES of attachment downloads (#212) — the established short form VS-NfD is used as the prefix VS-NfD_, single source classificationFilenamePrefix() in the same module.

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).