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
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>
90 lines
4.5 KiB
Markdown
90 lines
4.5 KiB
Markdown
# 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).
|