dorfteich/docs/architecture/data-model.md
Claude Fable 5 6aac785841
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m55s
CI / Build container images (pull_request) Successful in 3m0s
CI / Auth e2e pack (pull_request) Successful in 8m49s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m28s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m15s
CI / Import/export fidelity gate (push) Successful in 59s
#217: map IdP groups and roles onto the permission model
Declarative instance setting idpMapping.rules turns ID-token claims into
pond roles and the site-admin flag on every OIDC login — configuration,
not code. Mapped grants travel through the SAME GrantsService path as
manual ones (permission cache invalidated, collab access notify fires so
live sessions revalidate — asserted by test), never raw rows.

Ownership makes precedence explicit: role_grants.origin marks mapped
rows, users.is_site_admin_managed marks a mapping-set admin flag. The
mapping only creates and revokes what it owns — manual wins: hand-made
grants and hand-promoted admins are never revoked by a missing claim (a
manual toggle clears the marker and takes ownership). Removal of a claim
revokes the mapped grant and the managed flag on the next login. Every
mapping-driven change is audited with origin idp_mapping.

Failure containment: unknown pond slugs and the last-Pond-Admin
protection log-and-skip — a mapping problem must never become a login
lockout. Tests drive real OIDC logins against the fake IdP with group
claims: grant + working access, revocation incl. notify, manual-wins,
managed site-admin promote/demote/hands-off.

Documented in permissions.md (own section), ADR 0021, data-model.md and
the hardening guide (care rule: same PR).

Refs #217.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 13:09:11 +02:00

242 lines
14 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.

# Data model
Authoritative once implemented in `apps/api/prisma/schema.prisma`; this
document explains the entities and their intent. Naming below uses the
English terms (pond = Teich).
## Overview
```mermaid
erDiagram
users ||--o{ user_identities : "logs in via"
users ||--o{ sessions : has
users ||--o{ ponds : "created"
ponds ||--o{ pages : contains
ponds ||--o{ labels : defines
labels o|--o{ labels : "parent of"
pages }o--o{ labels : "tagged with"
pages ||--o{ page_versions : "has history"
pages ||--|| page_content_cache : "derived text"
pages ||--o{ page_links : "links to"
pages ||--o{ attachments : has
ponds ||--o{ attachments : owns
users ||--o{ role_grants : "subject of"
ponds ||--o{ role_grants : "scoped to"
plugins ||--o{ pond_plugins : "activated in"
ponds ||--o{ pond_plugins : activates
pages ||--o{ comments : has
users ||--o{ notifications : receives
users ||--o{ watches : sets
```
## Identity and access
### `users`
| Column | Notes |
| ----------------------------- | ----------------------------------------------------------- |
| `id` (uuid) | |
| `username` | unique, URL-safe |
| `email` | unique, stored verified/unverified with `email_verified_at` |
| `display_name` | shown at cursors, comments |
| `locale` | UI language (ADR 0012) |
| `is_site_admin` | boolean; Site Admin is a user flag, not a grant |
| `is_site_admin_managed` | true when the IdP claim mapping set the flag (issue #217) |
| `status` | `active` / `disabled` / `pending_verification` |
| `created_at`, `last_login_at` | |
### `user_identities` (ADR 0007)
`user_id`, `provider` (`password` now; `oidc:<issuer>` later), `subject`,
`credential` (Argon2id hash for `password`), unique on
(`provider`, `subject`).
### `sessions`
Opaque id (hashed), `user_id`, `created_at`, `expires_at`, `last_seen_at`,
user-agent summary (for "active sessions" UI).
### `auth_tokens`
Single-use tokens for e-mail verification and password reset: hashed token,
`purpose`, `expires_at`, `consumed_at`.
### `role_grants` — the permission table (see `permissions.md`)
| Column | Notes |
| -------------------------- | --------------------------------------------- |
| `id` | |
| `pond_id` | every grant belongs to exactly one pond |
| `subject_type` | `user` / `authenticated` / `public` |
| `subject_id` | user id when `subject_type = user`, else null |
| `role` | `pond_admin` / `editor` / `reader` |
| `scope_type` | `pond` / `label` / `page` |
| `scope_id` | label id or page id when scoped, else null |
| `effect` | `allow` / `deny` |
| `created_by`, `created_at` | audit |
| `origin` | `manual` / `idp` (claim mapping, issue #217) |
Unique on (`pond_id`, `subject_type`, `subject_id`, `role`, `scope_type`,
`scope_id`). `pond_admin` grants are only valid with `scope_type = pond`
and `subject_type = user`.
## Content
### `ponds`
| Column | Notes |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `id`, `slug` (unique), `name`, `description` | |
| `type` | `personal` (one per user, from self-signup) / `shared` |
| `owner_id` | creator; personal ponds: the user it belongs to |
| `settings` (jsonb) | fonts (ADR 0016), sidebar sort mode (`alpha` / `created` / `manual`), default page permissions |
| `deleted_at`, `deleted_by` | pond-level trash (ADR 0013) |
### `pages`
| Column | Notes |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `pond_id` | |
| `title` | also indexed for search weight A |
| `slug` | unique per pond, for stable URLs and wikilink resolution |
| `ydoc_state` (bytea) | current merged Yjs state (ADR 0003) |
| `ydoc_updates` | append log table `page_updates(page_id, seq, update bytea)`, compacted periodically |
| `sort_key` | manual sidebar ordering (fractional indexing) |
| `classification` | VS-NfD marking level, enum `UNCLASSIFIED` / `VS_NFD` (ADR 0022, issue #204). A marking, not a protection mechanism: permissions ignore it; separation of levels is one instance per level. Default for new pages from `instance_settings` (`classification.newPageDefault`). |
| `created_by`, `created_at`, `updated_at` | |
| `deleted_at`, `deleted_by` | trash |
### `page_content_cache`
One row per page, refreshed on persistence: `plain_text`, `markdown`,
`html`, `outline` (headings JSON, for TOC plugins), generated `tsvector`
column with GIN index (ADR 0010).
### `page_versions` (ADR 0013)
`page_id`, `ydoc_snapshot` (bytea, self-contained), `trigger`
(`auto` / `manual` / `pre_restore`), `label`, `contributor_ids`,
`created_at`.
### `labels`
`pond_id`, `name`, `parent_id` (nullable — hierarchy), `color`, unique on
(`pond_id`, `parent_id`, `name`). Cycles are rejected at write time.
`page_labels(page_id, label_id)` is the assignment table.
### `page_links`
Wikilink index maintained on persistence: `from_page_id`, `to_page_id`
(nullable when target does not exist yet — "phantom" links), `target_slug`.
Backlinks = query by `to_page_id`. Creating a page with a phantom-linked
slug resolves those rows.
### `attachments` (ADR 0011)
`id`, `pond_id`, `page_id` (nullable — pond-level files), `file_name`,
`mime_type`, `size_bytes`, `storage_path`, `uploaded_by`, `created_at`,
`deleted_at`.
## Plugins (ADR 0008)
### `plugins`
`id` (manifest id, primary key), `name`, `version`, `api_version`,
`kind` (`code` / `section_style`), `mode`
(`DISABLED` / `OPTIONAL` / `REQUIRED`, the instance mode — issue #72 sets it),
`manifest` (jsonb — the full validated manifest, so serving/admin views never
re-read disk), `installed_at`, `updated_at`, `removed_at` (soft-delete
tombstone: set on uninstall, files removed, so existing `plugin_block` nodes can
still resolve the manifest fallback). Unpacked bundles live on the plugins
volume at `<PLUGINS_DIR>/<id>/<version>/`; the path is derived from id+version,
not stored (issue #71).
### `pond_plugins`
(`pond_id`, `plugin_id`, `enabled`) — only meaningful for `optional`
plugins; `required` plugins are active everywhere. Rows cascade-delete with
their pond or plugin, and are dropped when a plugin is uninstalled.
## Quotas and settings
### `instance_settings`
Key-value (typed JSON) singleton set: registration mode, SMTP config
(secrets referenced from env, not stored plaintext — see `security.md`),
default quotas, upload allowlist, legal pages content (imprint, privacy),
instance default locale.
### `quota_overrides`
`subject_type` (`user` / `pond`), `subject_id`, `quota_key`
(`editors_per_pond`, `readers_per_pond`, `additional_ponds`,
`storage_bytes`, `max_file_bytes`), `value`. Resolution: pond override →
user override → instance default (most specific wins, mirroring the
permission philosophy).
### `pond_usage`
Cached counters per pond: `storage_bytes_used`, `editor_count`,
`reader_count` — updated transactionally, reconciled nightly.
## Collaboration support
- `collab_tokens` are **not** stored — they are short-lived signed JWTs
(ADR 0007).
- `mail_outbox`: pending/sent e-mails with retry state (ADR 0002 — no
broker).
- `jobs`: conversion jobs for import/export (ADR 0009) and maintenance jobs
(compaction, trash purge, quota reconciliation) with status + timestamps.
## Read-access trail (`read_events`, issues #222#224, ADR 0023)
One row per read of a `classification = vs_nfd` page per channel and dedup
window — separate from `audit_log` because volume, purpose and legal basis
all differ.
| Column | Notes |
| ---------------------------- | ------------------------------------------------------------------------------------ |
| `id`, `occurred_at` | composite PK (the partition key must be part of it) |
| `actor_id` | reader, null = anonymous; **no FK** — evidence outlives accounts |
| `session_key` | `session:<id>` / `token:<id>` / `job:<id>` / `anon` |
| `page_id`, `pond_id` | plain ids, **no FK** — evidence outlives purges |
| `channel` | `page_view` / `no_js_shell` / `public_api` / `attachment` / `export` / `collab_join` |
| `classification` | at read time; reclassification never rewrites history |
| `dedup_key`, `window_bucket` | unique pair per partition — race-free dedup (#223) |
| `window_seconds` | the row represents up to this many seconds of access |
| `details` | small context (export format, attachment id) — never content |
Storage shape (issue #224): RANGE-partitioned by month on `occurred_at`,
with a DEFAULT partition as safety net (a lagging maintenance job must
never make classified reads fail — the trail write is hard-failing by
design). The daily `read-trail-maintenance` job creates months ahead
(each with its per-partition dedup index) and applies the trail's own
retention `readTrail.retentionDays` (default 365): whole expired months
are DROPped without scanning, remainders deleted by range; every pruning
run is audited as `read_trail.pruned`. Growth, measured on PG 17:
**~1 MB per 1000 events** including indexes (≈970 bytes/row) — a
100-person instance at hundreds of classified reads/day stays in the tens
of MB per year.
Query path: `GET /admin/system/read-events` (Site Admin, filters
`pageId`, `actor`, `channel`, `from`/`to`) — deliberately API-only, no
panel: the trail is an examiner's tool, not a daily screen.
## Comments & notifications (later milestone)
- `comments`: `page_id`, `author_id`, `body` (Markdown), `anchor`
(optional serialized position), `resolved_at`, `created_at`, thread via
`parent_id`.
- `watches`: (`user_id`, `target_type` `page`/`pond`, `target_id`).
- `notifications`: `user_id`, `type`, `payload` (jsonb), `created_at`,
`read_at`; delivered in-app, optionally by e-mail digest.
## Deliberate non-entities
- **No `organizations`/`teams`** — ponds + grants cover the vision; groups
can be added as a new `subject_type` in `role_grants` without migration
pain.
- **No content translations** (ADR 0012).
- **No per-keystroke authorship** — contributor granularity is the version
snapshot (ADR 0013).