Prüfung and Freigabestatus
A Prüfung is one immutable record that a user checked one Typed block at one content state, with a kind such as redaktionell or fachlich. The record stores the block ID, the SHA-256 hash of the checked text, the kind, who recorded it (by), when (at), an optional note and the checked text itself (snapshot). A Prüfung stays valid while the hash of its block is unchanged. When the block changes, the hash changes, and the old record no longer matches.
The Freigabestatus is derived from the records and the current hashes. It is never stored:
| Level | Status | Meaning |
|---|---|---|
| Block, per kind | geprueft | a record of this kind exists at the current hash |
veraltet | records of this kind exist, none at the current hash | |
vorgeschlagen | no record of this kind, and the block is an AI proposal (a Derivation by the model, body not edited since); see AI proposals and the export check | |
offen | no record of this kind | |
| Document | freigegeben | every Typed block whose Block type lists the pruefung capability is geprueft for all its kinds |
offen | at least one such block is not |
A Prüfung is not a Block property. A property such as status is part of the document and any user who may edit the Block can write it. A Prüfung lives on the server outside the document, is written through its own route after a check of the user's right, and is pinned to the content (ADR-0002). Records key on block IDs, which live in the database; the stored document is canonical and Markdown is an import/export format (ADR-0001). A block that is copied or imported from Markdown gets a new ID and starts offen.
Enable it
The pruefung Feature is in no level list of the presets. A host that composes with composeEditor opts in by id, next to the framework binding of the preset: interaction: [...INTERACTION.full, 'pruefung']. A host that lists features itself adds pruefungFeature(). The Feature requires the composites Feature and the backend capability pruefung; a surface over a backend without that capability throws, and composeEditor reports the feature in omitted.
| Entry | Content |
|---|---|
@skaile/forge-editor/pruefung | framework-free: pruefungFeature, pruefungFeatureCore, pruefungCapability, pruefungHash, pruefungMarkdown, isBlockLocked, UNLOCK_KIND, createPruefungModel, createRestPruefungModel, pruefungModelOf and the types |
@skaile/forge-editor/pruefung/schema | Vue-free, server-importable: the record, route and store types and the capability config schema |
@skaile/forge-editor/pruefung/vue, @skaile/forge-editor/pruefung/react | pruefungFeature with the component of the Prüfstand section (PruefungBadge) |
The chips (Block, document) need no component: they come from the framework-free Feature. Only the inspector section is drawn by the binding, so import pruefungFeature from the /vue or /react entry to get it.
A Block type opts in by listing the pruefung capability in capabilities. The example gives the Block type contract two kinds, and adds the Feature to a preset:
import { compositeFeature } from '@skaile/forge-editor/composites/vue'
import { type BlockType, defaultBlockTypes } from '@skaile/forge-editor/composites/schema'
import { coreContentFeature, coreUiFeature, createTranslator, defineEditor } from '@skaile/forge-editor/core'
import { pruefungFeature } from '@skaile/forge-editor/pruefung'
// A Block type opts into the capability and configures it.
const contract: BlockType = {
id: 'contract',
label: 'Vertrag',
icon: 'i-lucide-file-signature',
capabilities: [
{ id: 'container', config: { accepts: 'all' } },
{ id: 'pruefung', config: { kinds: ['redaktionell', 'fachlich'] } },
],
}
const blockTypes = [...defaultBlockTypes(createTranslator('de')), contract]
// A Block type that lists a capability no Feature declares throws when the editor is created; so does a block action that
// `requires` an undeclared id, when the actions are collected.
export const preset = defineEditor({
features: [
coreContentFeature(),
coreUiFeature(),
compositeFeature({ blockTypes }),
pruefungFeature(),
],
})
Block capabilities in general are described in Block capabilities; the shape of pruefungCapability is the worked example there. A Block type that does not list pruefung has no Prüfung: no action, no chip, no section.
The capability config
The config is validated when the editor is created (z.strictObject: an unknown key throws).
| Key | Type | Meaning |
|---|---|---|
kinds | string[], at least one | the kinds a block of this type takes, in order |
chain | boolean, default false | a kind can be recorded only when every kind before it has a record at the block's current hash |
lock | string, optional | the kind whose Prüfung locks the block against edits while it is geprueft (Lock) |
What the hash covers
The hash is the SHA-256 (hex) of the title line, a blank line and the body Markdown of the block (pruefungMarkdown, pruefungHash). The same text is the snapshot of the record. A changed title or body makes the Prüfung veraltet. These are not covered, so changing them leaves a Prüfung valid: meta (Block properties, Derivation), tags, description, Baskets and the title visibility.
Who may record
Recording needs two things.
- The ability
pruefung.record(ABILITY.pruefungRecord, from@skaile/forge-editor/core/schema). No ability list contains it by default. A host grants it per mount through theabilitiesprop, for example{ root: [...ROOT_ABILITIES, ABILITY.pruefungRecord] }. Without it the block actions „Prüfung eintragen“ and „Sperre aufheben“ do not show. - The right of the user on the server.
GETandPOST /api/editor/pruefung/:idanswerkinds, the kinds that the requesting user may record. A read-only user gets[]. OtherwiseAccessControl.pruefungKinds(user, id)decides; when the host does not define it, every user who may edit gets the sentinel['*'], which the client reads as every configured kind.
AccessControl.pruefungKinds returns the kind names that the user may record in that document:
const identity: AccessControl = {
// ...authenticate, authorize
async pruefungKinds(user, id) {
return isEditorialStaff(user) ? ['redaktionell'] : ['redaktionell', 'fachlich', 'entsperrt']
},
}
A list that names kinds explicitly applies exactly. A user who may lift a lock needs entsperrt (UNLOCK_KIND) in it, because „Sperre aufheben“ records that kind. A client shows an action only for a kind in kinds; the server enforces it again.
The server port
The route group needs the port pruefung of EditorPorts, a PruefungStore (@skaile/forge-editor/pruefung/schema):
| Method | Does |
|---|---|
list(documentId) | the records of one document, oldest first; empty for an unknown document |
record(entry) | stores entry under a new ID and returns the record |
Records are never changed or deleted: a later check is a new record. The port provides the backend capability pruefung; without it the server mounts no Prüfung route and the Feature is not available. createJsonAdapter (@skaile/forge-editor/adapters/json) wires a JSON store that keeps the records of all documents in one file, .pruefung.json, in the adapter's data directory (a dot-named file, so it is not read as a document). A host with its own database implements PruefungStore and passes it as ports.pruefung.
The routes:
| Route | Does |
|---|---|
GET /api/editor/pruefung/:id | answers { records, kinds } |
POST /api/editor/pruefung/:id | body { blockId, hash, kind, note?, snapshot, kinds, chain }; records one Prüfung and answers { records, kinds } |
kinds and chain in the body echo the capability config. The server uses only their order to apply chain; it never takes a right from them. It computes the stored hash itself, from snapshot.
Refusals
A refused POST answers an error whose data is { reason }. A client switches on reason, not on the message. The checks run in this order:
| Reason | HTTP | Message | When |
|---|---|---|---|
| (none) | 400 | Ungültiger JSON-Body | the body is not JSON or a member has the wrong type, or blockId, hash or kind is empty |
role | 403 | Keine Berechtigung für diese Prüfung | the user is read-only, or may not record this kind |
stale | 409 | Der Block hat sich seit der Prüfung geändert | the SHA-256 of snapshot is not hash |
duplicate | 409 | Diese Prüfung liegt für diesen Stand bereits vor | the block already has a record of this kind at this hash |
needs-<kind> | 409 | Zuerst ist die Prüfung „<kind>“ nötig | chain is on and <kind>, an earlier kind in kinds, has no record at this hash |
The REST model shows the message of a refused record as a warning notice. stale is weaker than its name: the server has no Markdown serializer for a block, so it cannot rebuild the current text from the document. It proves only that snapshot and hash belong together. The client builds both from the live block, so a block that changed between the click and the answer is the client's case (ADR-0002, Consequences).
A kind that is not in kinds (the unlock record entsperrt) is not part of the chain.
Lock
lock: '<kind>' in the capability config locks a block while that kind is geprueft at the block's current hash. The lock is a transaction guard, not a stored flag. A local edit inside a locked block is dropped whole and the editor shows the notice „Block ist freigegeben und gesperrt“. The guard hashes only the blocks that a transaction touches and only those whose Block type sets lock. Transactions that carry what another user wrote (Yjs sync) pass, because refusing them would make this client differ from the shared document.
The block action „Sperre aufheben“ (ability pruefung.record; visible only on a locked block, for a user whose kinds include entsperrt) records a Prüfung of kind entsperrt at the current hash. A block with such a record at its current hash is not locked, and the history of who lifted the lock stays in the records. After the next edit the hash changes, the kind is veraltet and the lock is off until someone records the kind again. Any change of the hash lifts the lock, including a collaborator's edit or an import.
What the user sees
| Where | What |
|---|---|
| Block action „Prüfung eintragen“ | with one recordable kind, a click records it; with several, a menu lists the kinds and shows „Für diesen Stand geprüft“ for kinds that are already geprueft. Kinds that the user may not record are not listed |
| Chip after the title of a block | „Geprüft“ (success) when every kind is geprueft; „Prüfung veraltet“ (warning) when a kind is veraltet; nothing otherwise |
| Inspector and details card, section „Prüfstand“ | the kinds with their status, the records (kind, user, date, note), and „Geprüften Stand anzeigen“, which shows the checked text and the current text in two read-only columns, without a diff |
| „Dokument“ header of the inspector, and the open document's row in the Block outline of the Document tree | the Freigabestatus: „Freigegeben“, or „Offen (n Blöcke)“; nothing while no block opts in |
Add your own chips and sections
The composites Feature draws the chips and sections of other Features and knows nothing about Prüfung. Three optional members of EditorFeature (@skaile/forge-editor/core) carry them; all three are additive in Feature order and the factory runs once per surface:
| Member | Contribution | Drawn |
|---|---|---|
rowBadges(ctx) | RowBadge[]: { id, of(editor, row), subscribe? }; of returns a BadgeChip ({ label, tone: 'success' | 'warning' }, label already translated) or null | after the title of a Typed block |
documentBadges(ctx) | DocumentBadge[]: the same, of(editor) without a row | after the „Dokument“ heading of the inspector and on the open document's row of the Block outline |
inspectorSections(ctx) | InspectorSection<C>[]: { id, title, component, appliesTo? }; the component of the framework layer receives { editor, node } | below the fields of a Typed block in the inspector and the details card |
of runs for every row on every redraw, so it must stay cheap: look at row.capabilities first. subscribe(editor, listener) reports changes of what of reads that are not editor transactions (the Prüfung model loads its records later); the chrome calls of again after each listener call, and subscribe returns the unsubscribe function. This Feature puts a chip on every block whose meta.status is entwurf:
import type { EditorFeature, RowBadge } from '@skaile/forge-editor/core'
const draftChip: RowBadge = {
id: 'draft',
of: (editor, row) =>
editor.state.doc.nodeAt(row.pos)?.attrs.meta?.status === 'entwurf'
? { label: 'Entwurf', tone: 'warning' }
: null,
}
export const draftFeature: EditorFeature = {
id: 'draft-chip',
dimension: 'interaction',
requires: ['composites'],
rowBadges: () => [draftChip],
}
pruefungFeatureCore(ui, options) is the same Feature without a framework: ui.section is the component of the Prüfstand, and without ui the Feature adds no section. PruefungFeatureOptions.model replaces the REST model with a factory for a host's own store; the Feature then needs no pruefung backend capability. For the model, pruefungModelOf(editor) gives state, refresh, record(blockId, kind, note?), mayRecord(kind), statusOf(node) and freigabeOf(editor) ({ status, open, total }).
Image studio
The Bild-Studio block: a picture with its description, prompt blocks, references, previous generations and chat. The node, its Markdown form, the state reducers, the images backend port and the dialog for your own store.
Workspace: find, read, reference and embed Blocks across Documents
The Block index, Workspace search, Block references and Embeds that name a Block address, Document routes with the DocumentMutator, Tool providers for the document chat, and change sets for edits to other Documents.