---
title: "Prüfung and Freigabestatus"
description: "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."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/pruefung"
---
# 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:

<table>
<thead>
  <tr>
    <th>
      Level
    </th>
    
    <th>
      Status
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Block, per kind
    </td>
    
    <td>
      <code>
        geprueft
      </code>
    </td>
    
    <td>
      a record of this kind exists at the current hash
    </td>
  </tr>
  
  <tr>
    <td>
      
    </td>
    
    <td>
      <code>
        veraltet
      </code>
    </td>
    
    <td>
      records of this kind exist, none at the current hash
    </td>
  </tr>
  
  <tr>
    <td>
      
    </td>
    
    <td>
      <code>
        vorgeschlagen
      </code>
    </td>
    
    <td>
      no record of this kind, and the block is an AI proposal (a Derivation by the model, body not edited since); see <a href="https://skaile-ai.github.io/forge-editor/guides/export-check">
        AI proposals and the export check
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      
    </td>
    
    <td>
      <code>
        offen
      </code>
    </td>
    
    <td>
      no record of this kind
    </td>
  </tr>
  
  <tr>
    <td>
      Document
    </td>
    
    <td>
      <code>
        freigegeben
      </code>
    </td>
    
    <td>
      every Typed block whose Block type lists the <code>
        pruefung
      </code>
      
       capability is <code>
        geprueft
      </code>
      
       for all its kinds
    </td>
  </tr>
  
  <tr>
    <td>
      
    </td>
    
    <td>
      <code>
        offen
      </code>
    </td>
    
    <td>
      at least one such block is not
    </td>
  </tr>
</tbody>
</table>

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](https://github.com/skaile-ai/forge-editor/blob/main/docs/adr/0002-pruefung-records-outside-the-document.md)). Records key on block IDs, which live in the database; the stored document is canonical and Markdown is an import/export format ([ADR-0001](https://github.com/skaile-ai/forge-editor/blob/main/docs/adr/0001-database-canonical-markdown-export.md)). 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`.

<table>
<thead>
  <tr>
    <th>
      Entry
    </th>
    
    <th>
      Content
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        @skaile/forge-editor/pruefung
      </code>
    </td>
    
    <td>
      framework-free: <code>
        pruefungFeature
      </code>
      
      , <code>
        pruefungFeatureCore
      </code>
      
      , <code>
        pruefungCapability
      </code>
      
      , <code>
        pruefungHash
      </code>
      
      , <code>
        pruefungMarkdown
      </code>
      
      , <code>
        isBlockLocked
      </code>
      
      , <code>
        UNLOCK_KIND
      </code>
      
      , <code>
        createPruefungModel
      </code>
      
      , <code>
        createRestPruefungModel
      </code>
      
      , <code>
        pruefungModelOf
      </code>
      
       and the types
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/pruefung/schema
      </code>
    </td>
    
    <td>
      Vue-free, server-importable: the record, route and store types and the capability config schema
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/pruefung/vue
      </code>
      
      , <code>
        @skaile/forge-editor/pruefung/react
      </code>
    </td>
    
    <td>
      <code>
        pruefungFeature
      </code>
      
       with the component of the Prüfstand section (<code>
        PruefungBadge
      </code>
      
      )
    </td>
  </tr>
</tbody>
</table>

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:

```ts [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](https://skaile-ai.github.io/forge-editor/guides/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).

<table>
<thead>
  <tr>
    <th>
      Key
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        kinds
      </code>
    </td>
    
    <td>
      <code>
        string[]
      </code>
      
      , at least one
    </td>
    
    <td>
      the kinds a block of this type takes, in order
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        chain
      </code>
    </td>
    
    <td>
      <code>
        boolean
      </code>
      
      , default <code>
        false
      </code>
    </td>
    
    <td>
      a kind can be recorded only when every kind before it has a record at the block's current hash
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        lock
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
      
      , optional
    </td>
    
    <td>
      the kind whose Prüfung locks the block against edits while it is <code>
        geprueft
      </code>
      
       (<a href="#lock">
        Lock
      </a>
      
      )
    </td>
  </tr>
</tbody>
</table>

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

```ts
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`):

<table>
<thead>
  <tr>
    <th>
      Method
    </th>
    
    <th>
      Does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        list(documentId)
      </code>
    </td>
    
    <td>
      the records of one document, oldest first; empty for an unknown document
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        record(entry)
      </code>
    </td>
    
    <td>
      stores <code>
        entry
      </code>
      
       under a new ID and returns the record
    </td>
  </tr>
</tbody>
</table>

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:

<table>
<thead>
  <tr>
    <th>
      Route
    </th>
    
    <th>
      Does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        GET /api/editor/pruefung/:id
      </code>
    </td>
    
    <td>
      answers <code>
        { records, kinds }
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /api/editor/pruefung/:id
      </code>
    </td>
    
    <td>
      body <code>
        { blockId, hash, kind, note?, snapshot, kinds, chain }
      </code>
      
      ; records one Prüfung and answers <code>
        { records, kinds }
      </code>
    </td>
  </tr>
</tbody>
</table>

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

<table>
<thead>
  <tr>
    <th>
      Reason
    </th>
    
    <th>
      HTTP
    </th>
    
    <th>
      Message
    </th>
    
    <th>
      When
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      (none)
    </td>
    
    <td>
      400
    </td>
    
    <td>
      <code>
        Ungültiger JSON-Body
      </code>
    </td>
    
    <td>
      the body is not JSON or a member has the wrong type, or <code>
        blockId
      </code>
      
      , <code>
        hash
      </code>
      
       or <code>
        kind
      </code>
      
       is empty
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        role
      </code>
    </td>
    
    <td>
      403
    </td>
    
    <td>
      <code>
        Keine Berechtigung für diese Prüfung
      </code>
    </td>
    
    <td>
      the user is read-only, or may not record this kind
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        stale
      </code>
    </td>
    
    <td>
      409
    </td>
    
    <td>
      <code>
        Der Block hat sich seit der Prüfung geändert
      </code>
    </td>
    
    <td>
      the SHA-256 of <code>
        snapshot
      </code>
      
       is not <code>
        hash
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        duplicate
      </code>
    </td>
    
    <td>
      409
    </td>
    
    <td>
      <code>
        Diese Prüfung liegt für diesen Stand bereits vor
      </code>
    </td>
    
    <td>
      the block already has a record of this kind at this hash
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        needs-<kind>
      </code>
    </td>
    
    <td>
      409
    </td>
    
    <td>
      <code>
        Zuerst ist die Prüfung „<kind>“ nötig
      </code>
    </td>
    
    <td>
      <code>
        chain
      </code>
      
       is on and <code>
        <kind>
      </code>
      
      , an earlier kind in <code>
        kinds
      </code>
      
      , has no record at this hash
    </td>
  </tr>
</tbody>
</table>

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](https://github.com/skaile-ai/forge-editor/blob/main/docs/adr/0002-pruefung-records-outside-the-document.md), 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

<table>
<thead>
  <tr>
    <th>
      Where
    </th>
    
    <th>
      What
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Block action „Prüfung eintragen“
    </td>
    
    <td>
      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 <code>
        geprueft
      </code>
      
      . Kinds that the user may not record are not listed
    </td>
  </tr>
  
  <tr>
    <td>
      Chip after the title of a block
    </td>
    
    <td>
      „Geprüft“ (success) when every kind is <code>
        geprueft
      </code>
      
      ; „Prüfung veraltet“ (warning) when a kind is <code>
        veraltet
      </code>
      
      ; nothing otherwise
    </td>
  </tr>
  
  <tr>
    <td>
      Inspector and details card, section „Prüfstand“
    </td>
    
    <td>
      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
    </td>
  </tr>
  
  <tr>
    <td>
      „Dokument“ header of the inspector, and the open document's row in the Block outline of the Document tree
    </td>
    
    <td>
      the Freigabestatus: „Freigegeben“, or „Offen (n Blöcke)“; nothing while no block opts in
    </td>
  </tr>
</tbody>
</table>

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

<table>
<thead>
  <tr>
    <th>
      Member
    </th>
    
    <th>
      Contribution
    </th>
    
    <th>
      Drawn
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        rowBadges(ctx)
      </code>
    </td>
    
    <td>
      <code>
        RowBadge[]
      </code>
      
      : <code>
        { id, of(editor, row), subscribe? }
      </code>
      
      ; <code>
        of
      </code>
      
       returns a <code>
        BadgeChip
      </code>
      
       (<code>
        { label, tone: 'success' | 'warning' }
      </code>
      
      , label already translated) or <code>
        null
      </code>
    </td>
    
    <td>
      after the title of a Typed block
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        documentBadges(ctx)
      </code>
    </td>
    
    <td>
      <code>
        DocumentBadge[]
      </code>
      
      : the same, <code>
        of(editor)
      </code>
      
       without a row
    </td>
    
    <td>
      after the „Dokument“ heading of the inspector and on the open document's row of the Block outline
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        inspectorSections(ctx)
      </code>
    </td>
    
    <td>
      <code>
        InspectorSection<C>[]
      </code>
      
      : <code>
        { id, title, component, appliesTo? }
      </code>
      
      ; the component of the framework layer receives <code>
        { editor, node }
      </code>
    </td>
    
    <td>
      below the fields of a Typed block in the inspector and the details card
    </td>
  </tr>
</tbody>
</table>

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

```ts
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 }`).


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
