All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m34s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CI / Lint, typecheck, test (pull_request) Successful in 6m19s
CI / Build container images (pull_request) Successful in 1m14s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m24s
CI / Import/export fidelity gate (push) Successful in 59s
Stefan reviewed and accepted all nine VS-NfD ADRs one by one. Two adjustments from the review: ADR 0021 decision 3 now states the #216 refinement in the decision itself (PAT/feed-token issuance stays available to IdP-authenticated sessions — API authorization under its own switches, not interactive sign-in) instead of contradicting the later Decisions section; and the ADR 0020 dual-verify window will be removed early (issue #296) rather than waiting for its stated expiry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
4.5 KiB
4.5 KiB
ADR 0022: Classification as first-class page metadata
- Status: accepted (2026-07-31)
- 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:
Labelis 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.tsloadslabelIdsonly to feedpermissions.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
- A dedicated enum field on
Page, with an instance-wide default frominstance_settings. Not labels, for the four reasons above. - 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.
- 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.
- 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.
- 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.
- Unclassified content shows no marking. Marking everything trains users to ignore markings.
- 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 isclassificationMarking()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 formVS-NfDis used as the prefixVS-NfD_, single sourceclassificationFilenamePrefix()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
docToHtmlpath 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).