Guides

Prüfung and Freigabestatus

Record that a user checked a Typed block at one content state, derive the Freigabestatus, optionally lock checked blocks, and add your own chips and inspector sections.

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:

LevelStatusMeaning
Block, per kindgepruefta record of this kind exists at the current hash
veraltetrecords of this kind exist, none at the current hash
vorgeschlagenno 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
offenno record of this kind
Documentfreigegebenevery Typed block whose Block type lists the pruefung capability is geprueft for all its kinds
offenat 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.

EntryContent
@skaile/forge-editor/pruefungframework-free: pruefungFeature, pruefungFeatureCore, pruefungCapability, pruefungHash, pruefungMarkdown, isBlockLocked, UNLOCK_KIND, createPruefungModel, createRestPruefungModel, pruefungModelOf and the types
@skaile/forge-editor/pruefung/schemaVue-free, server-importable: the record, route and store types and the capability config schema
@skaile/forge-editor/pruefung/vue, @skaile/forge-editor/pruefung/reactpruefungFeature 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:

docs/snippets/pruefung-preset.ts
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).

KeyTypeMeaning
kindsstring[], at least onethe kinds a block of this type takes, in order
chainboolean, default falsea kind can be recorded only when every kind before it has a record at the block's current hash
lockstring, optionalthe 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 the abilities prop, 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. GET and POST /api/editor/pruefung/:id answer kinds, the kinds that the requesting user may record. A read-only user gets []. Otherwise AccessControl.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):

MethodDoes
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:

RouteDoes
GET /api/editor/pruefung/:idanswers { records, kinds }
POST /api/editor/pruefung/:idbody { 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:

ReasonHTTPMessageWhen
(none)400Ungültiger JSON-Bodythe body is not JSON or a member has the wrong type, or blockId, hash or kind is empty
role403Keine Berechtigung für diese Prüfungthe user is read-only, or may not record this kind
stale409Der Block hat sich seit der Prüfung geändertthe SHA-256 of snapshot is not hash
duplicate409Diese Prüfung liegt für diesen Stand bereits vorthe block already has a record of this kind at this hash
needs-<kind>409Zuerst ist die Prüfung „<kind>“ nötigchain 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

WhereWhat
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 treethe 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:

MemberContributionDrawn
rowBadges(ctx)RowBadge[]: { id, of(editor, row), subscribe? }; of returns a BadgeChip ({ label, tone: 'success' | 'warning' }, label already translated) or nullafter the title of a Typed block
documentBadges(ctx)DocumentBadge[]: the same, of(editor) without a rowafter 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 }).

Copyright © 2026