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

87 lines
4.3 KiB
Markdown
Raw 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: 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).