dorfteich/docs/architecture/adr/0022-page-classification.md
Claude Fable 5 404a3741c8
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
ADRs 0019-0027: accepted after explicit operator review (2026-07-31)
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
2026-07-31 20:47:27 +02:00

90 lines
4.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:
- `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).