0009. A document has an identity, a repository, an updater and two post-commit events¶
Status: Accepted; the change origin paragraph's three cases are superseded by 0039 (2026-10-31)
Date: 2026-10-31
Context¶
The module needed four seams: a reader answering with a document and its identity, a writer applying a validated change against an expected base revision and a content token under a lock, two server events reporting what was persisted, and three client events carrying the same identity. The proof asked of them is that an extension reads a revision, proposes a change, and is refused once a newer revision exists.
0001 settled what names a document. The item's uuid is minted in BlockNoteDocumentItem::preSave() when absent and regenerated when it collides, one identity per item and per translation, and the delta is explicitly not stable because a reorder moves it.
Four facts about core decided the shape rather than taste. SqlContentEntityStorage::save() and delete() wrap parent::save() and the hook invocations in startTransaction() and commitOrRelease(), so an entity hook always runs inside a transaction for a SQL content entity and a subscriber called from one would see uncommitted state. TransactionManagerBase::addPostTransactionCallback() throws \LogicException when no transaction is active, passes callbacks a success flag that is FALSE on a rollback, and runs them from Transaction::__destruct(), so an exception escaping one is thrown from a destructor; CacheTagsChecksumTrait is core's worked example. ContentEntityStorageBase::doPostSave() invokes the translation hooks before hook_entity_update. DatabaseLockBackend writes its semaphore row on the connection the entity save uses. And core evaluates AllowedValues on every filter_format property against the acting account on every validation.
Two module facts matter as much. DocumentParser::parse() accepts exactly one envelope version, refuses a missing or non-int version as DocumentError::InvalidVersion with an empty context and any other int as DocumentError::UnsupportedVersion carrying the found version under @version; DocumentNormalizer::stampVersion() existed before this record and was called from nowhere. And on an add form the host has no id and no revision id and preSave() has not run, so the item carries no uuid and client events before the first save would be unkeyed. A form with nothing on it that caches the form is built afresh on its first submit rather than rebuilt from a cache, so a value kept only in the widget state does not survive that submit.
Decision¶
DocumentIdentity is the one way a document is named, and its array form is the one payload. A final readonly class carrying uuid, entityTypeId, bundle, entityId, revisionId, langcode, fieldName, delta and schemaVersion, three of them nullable for three reasons: an unsaved host has no entity id, a non-revisionable type has no revision id, an unminted document has no uuid. bundle is on it although the first list of its members did not name it, because a field definition and an upload target are bound to a bundle and a consumer holding the identity should not load the entity to learn it. toArray() fixes the key order uuid, entity_type, bundle, entity_id, revision_id, langcode, field, delta, schema_version, and that array is the widget attribute, the JS event detail and the PHP events alike. key() is document:<uuid>. There is no hash member: a hash belongs to a value at a moment, an identity does not.
The hash is sha256: followed by hash('sha256', $value) over the raw stored bytes, computed by blocknote.document_hasher and nowhere else. Never over a re-encode: the client returns envelope members it did not understand, so a re-serialisation in PHP can differ from what was stored, and a normalising hash makes two different stored strings compare equal, which is how a concurrent write disappears silently. The algorithm sits in the prefix so a later change is legible in stored tokens, and the empty string hashes like any other. The hash lives on StoredDocument, the widget attribute and the events, never as a field property.
Reads are not access checked, writes are. DocumentRepository takes no current_user and answers whatever it finds; a caller putting a document in front of a request owns view access, and docs/workflows.md says so. DocumentUpdater checks entity update access on the translation it writes, $translation->access('update', $actor, TRUE), and the field's edit access for the actor; createTranslation() also asks content_translation's getTranslationAccess($entity, 'create') as the actor when that module translates the entity type; and a refusal is answered as Forbidden rather than thrown. The reason for the asymmetry: a write has an actor and produces a revision attributed to somebody, so refusing it is meaningful and recordable, while a read has neither. 0007's upload route adds neither check, being CSRF-bound to a field the widget rendered for a person in their own session.
The repository reports which revision it read. DocumentRevision names Active, Default, Latest, LatestTranslationAffected and Named, the last refused when passed in and only reported, for an identity carrying a revision id; the docblock writes out the core call each word maps to. Translations resolve strictly and a missing one is NULL rather than the default language, and a revision id that belongs to another entity is NULL too. A revision id on the identity wins over the word passed in, is read at exactly that revision and is reported as Named; a caller that wants the word to win passes withRevisionId(NULL) first. One rule governs a uuid and a delta that disagree: the uuid at the delta is verified, a mismatch is healed by finding the item that carries the uuid in the same field and translation, the neighbour sitting at the requested delta is never returned, and no item carrying the uuid answers NULL. loadByUuid() queries the indexed uuid column per entity type and field, applies the word in the language of the translation carrying the document, warns when two hosts match, and says in its docblock that its default path reads the field data table and misses a uuid living only in a pending revision.
Staleness is the revision first and the hash second, and access is answered before both. Inside the lock and under the host row the updater reads the document through the repository at DocumentRevision::Active, EntityRepository::getActive() for the identity's langcode, past the static cache, so the read and the write share one rule; access is answered before staleness, so Stale and the document it carries go only to an actor who may write it. A revision id read that differs from the caller's is Stale; equal ids with a hash that does not match is the same-revision edit and is Stale too. The order is what makes a revert behave: a revert produces a new revision whose bytes may equal the ones a caller read, and such a byte-identical token is stale because the revision moved.
Six result kinds, because a caller acts differently on each. Saved carries the new StoredDocument, Stale the one now in the store, Invalid the violations, Locked a retry-after, Forbidden the access result, Gone nothing. Gone is not folded into Stale: an approval workflow retries a stale proposal and must never retry a deleted document forever. A malformed document is Invalid, never a \JsonException, and validation runs $translation->validate() filtered by the actor's field access, the chain JSON:API runs, so what the prepare callback changed is answered for the actor with the document.
The lock is acquired outside the transaction and released outside it, in a finally. It is named blocknote_document:<uuid>, keyed on the document because the field and the delta are what a reorder changes, and taken from the injected lock service, never \Drupal::lock() and never lock.persistent. An identity carrying no uuid or no entity id names no stored document, and it is answered Gone before any lock is taken, so such calls do not share one lock. The ordering is commented as load-bearing, because the lock backend shares the save's connection. The item is located by uuid and written in place, never by delta, so a reorder between read and write is a reorder and the update proceeds there. The updater refuses to run inside an open transaction, because inside another transaction the semaphore row would be uncommitted, a competing writer would wait on it instead of being answered Locked, and Saved would be answered before anything was durable. It opens its own transaction inside the try the lock's finally guards, so a transaction that cannot start still frees the lock, and it holds the host's base-table row with SELECT ... FOR UPDATE from its read to its commit. Every save of an existing revisionable document host takes the same row in presave, in BlockNoteDocumentHostLockHooks, because a save that only adds a pending revision never writes the base row. Amended 2026-10-31. A refused result (Stale, Invalid, Forbidden, Gone) rolls the transaction back and no longer commits it, so that nothing was written holds by construction. Every step that ends a write runs in that order: the rollback, the switch back to the previous account, the change origin forgotten, the static cache reset, which follows the rollback because core drops the cache invalidations queued in a rolled back transaction. A step that throws no longer keeps the steps after it from running or the lock from being released, and the first failure is thrown once all have run.
A storage exception is not a result, and there is no pre-save event. An EntityStorageException or any other throwable rolls back, releases the lock, dispatches nothing and propagates: a throwing hook_entity_presave is a defect in some module, not an outcome a caller can act on. A pre-save event would be a second contract with a second failure story nobody asked for, and blocknote_test proves rollback with a throwing presave hook for nothing written and a throwing update hook for rows written and an event queued, then taken back.
Both events are dispatched after the outermost commit, and a subscriber that throws is logged. PostTransactionDispatcher dispatches immediately when the connection is not in a transaction, and otherwise queues and registers one post-transaction callback for the queue, only when the queue was empty. The queue is dispatched in registration order and cleared either way. An event queued directly in the root transaction, or by DocumentUpdater in its own, is dispatched without a check when the callback is told the root committed; one queued inside a savepoint, or raised while events are being dispatched, is dispatched only if what it reports is found in storage at the flush, because core tells a post-transaction callback only the root's outcome and runs a root's callbacks before it forgets that root. A callback told that the root rolled back checks every event that way and drops one queued without a check, because core reads that outcome from the connection again for each callback, and an earlier callback that ended a transaction of its own changes it. The flush that is running also dispatches what a subscriber's own write queued, after the events already queued, on a core that runs the callbacks of the subscriber's transaction when it ends as on one that does not: a callback that runs while the queue is being flushed hands its events to the running flush, each with the outcome it was told. Each dispatch is logged with Error::logException() and never rethrown, because the data is committed and the callback is reached from a destructor. The limit docs/workflows.md states is that the catch is per event and not per listener. The queue is not flushed by the commit: callbacks run from TransactionManagerBase::processPostTransactionCallbacks(), which only purge() reaches, so the flush happens when the root Transaction object is destroyed. In the updater that is its last statement, after the lock has been released, so a subscriber runs on committed state with no lock held and may call DocumentUpdater for the same document.
The storage check and the same-pass flush are a compatibility path for three core defects. No released or development version of core fixes any of them: a callback registered inside the transaction of a running post-transaction callback waits for a later root transaction (#3447097), a callback registered inside a savepoint that is rolled back still runs, told that the root transaction committed, and a callback that runs after another callback which ended a transaction of its own is told how that transaction ended; no core issue reports the last two yet. Told that a root transaction which rolled back committed, the dispatcher cannot tell from inside its callback and dispatches the events that root vouches for, which docs/workflows.md records as a limit. The path is kept for every core version the module supports. Amended 2026-10-30. Core's database maintainer opened #3634304, which deprecates post-transaction callbacks in drupal:12.1.0 in favor of transaction events. The module's requirements of that design, and the question whether fixes to the callback path are still wanted for 11.x and 12.0.x, which keep it, were posted there as comment #4 with a test-only patch. It awaits an answer, so the path is kept as described here.
What fires is decided by a diff of the entity in memory against the revision the save is compared with. DocumentChangeDetector snapshots every blocknote_document field of every translation, source language first, reads a non-translatable field once, skips an item with no uuid with one debug log, and keys everything by document uuid. A save that stays a pending revision is compared with the revision it was loaded from, core's getOriginal(), which costs nothing; a save that makes a revision the default one when it was not loaded as the default one is compared with the default revision it replaces, read once in a hook_entity_presave implementation that runs after the others, so publishing a draft fires when the published bytes change. An insert fires one DocumentSaved per document with no previous identity. An update fires DocumentSaved when the hash differs or the position differs, position being entity type, entity id, langcode, field and delta, so a pure reorder fires with equal hashes and a previous identity whose delta differs, while a save changing neither fires nothing, a new revision of an untouched document included. An update fires DocumentDeleted for a uuid that was in the baseline and is gone, the cause being TranslationRemoved when that language is gone and ItemRemoved otherwise. A delete fires it with EntityDeleted for every document of every translation of every revision, gathered in hook_entity_predelete at one query per document field, so a document only a pending revision held is reported too. The translation hooks are not implemented, because core invokes them first and either would report a document twice.
Nothing fires on revision delete. Deleting an old revision does not delete the document, and telling an annotation store to drop anchors for a live document is worse than telling it nothing. docs/workflows.md names hook_entity_revision_delete as where a DocumentRevisionDeleted would go, and records that 1.0 has none.
Change origin is marked, never inferred. ChangeOrigin has three cases: the widget marks Widget when it extracts form values, the updater marks Updater around its save and forgets afterwards, and every other writer, REST, JSON:API, migrations, cron and a module's own save() alike, arrives as Unmarked. An origin that guesses is worse than one saying it does not know.
Nothing here claims anything about Workspaces. An active workspace changes what Default, Latest and Active resolve to, the docblock says so, and 1.0 writes no code for it.
The widget names the document in formElement(), and the browser never hashes. When an item carries no uuid the widget mints one and keeps it in the widget state, so that add another, a format switch and an upload rebuild agree with what the client was already shown, and the attribute on an add form carries a real uuid with a NULL entity id and revision id, which a client reconciles after the first save through loadByUuid(). The hash on the attribute is the document as loaded and is echoed back unchanged: hashing in the browser would answer again, over bytes it may have re-serialised, a question the server already answered.
A document uuid posted from the widget is untrusted input, and it rides in a hidden element. The identity is not part of the text_format value, so it is its own child of the element; it is a hidden element rather than a value one because a value element is not posted and a form that was never cached has no widget state to read the mint back from, which is exactly the shape of an add form carrying this field alone. What comes back is therefore whatever the browser sent: it is accepted only when Uuid::isValid() says it is a uuid, and anything else is ignored and the document is named afresh. It is read only for an item that has no stored identity; a stored item's identity is the server's and a posted one is ignored. A posted identity is still not trusted: preSave() checks any identity its host did not already store against every revision of every document field and regenerates it on a collision. A client therefore correlates on the uuid it was shown on the add form, and that is the uuid stored unless it collided.
stampVersion() refuses a version it has no migration for. DocumentUpdater calls DocumentNormalizer::stampVersion() straight after parsing, before it validates. The method returns a document at DocumentParser::SCHEMA_VERSION as it is and throws \LogicException naming any other version, because relabeling a document would store blocks written for one schema under another schema's number. The parser refuses such a document first, so the exception guards against a later parse path that accepts more. The widget path does not call it. The client opens an envelope only when its version equals the schemaVersion the editor plugin sends, a missing version included, shows the unreadable-document notice otherwise, and writes back the envelope it received with only blocks replaced and no version added. The stamp is where a later schema version's migration runs on the programmatic path.
Alternatives considered¶
Naming the object DocumentHandle and putting the hash on it. Rejected. A handle carrying a hash is an identity and a value at once, while the hash's meaning is that it belongs to one read at one moment. xxh128 lost to sha256 for a neighbouring reason: the token is written into HTML attributes and stored elsewhere, where a familiar algorithm name costs nothing.
Access checks on reads. Rejected. The repository has no request, no actor and no output, so any answer would be guessed from current_user and wrong inside cron, a queue worker or an account switch.
A per-host lock through the lock backend. Rejected. DatabaseLockBackend::acquire() is re-entrant within a request, a nested release frees the outer holder, and no form save takes it. The host row excludes every writer, a writer that never calls the updater included.
Re-checking against loadUnchanged() just before the save. Rejected. The window between the check and the write remains, and the check costs a load.
A queue per transaction depth. Rejected. Core reports neither a savepoint release nor a savepoint rollback, so a depth's queue could never be closed.
Core's transaction events. Not available: core 11.4 dispatches statement events only.
Checking every event against storage, or a canary row per queued event. Rejected. The first loads every changed host on every save, and the second writes a row on every save and would need its table created on demand inside a transaction. The check is paid only by an event the root transaction cannot vouch for, and by any event whose callback is told the root rolled back.
Consequences¶
Two concurrent updates to two documents of one entity are not excluded by the document lock. The host row the updater holds from its read to its commit makes the second wait until the first has committed, and the second then reads what the first wrote, answering Stale when the revision moved. That holds only because the updater reads uncached inside that row lock and never accepts an entity object from the caller, which an update(EntityInterface $entity, ...) overload would undo silently.
A subscriber runs after the commit and after the lock has been released, so it cannot roll the change back and its own writes are outside Drupal's transaction. An extension that reacts by writing and must not lose the work queues it.
Four limits are recorded rather than closed. An event checked at the flush reads storage after the commit, so a concurrent write to the same document in between can suppress it, and that write fires its own event. TransactionManagerBase::stackDepth() is not on TransactionManagerInterface, so a transaction manager that does not extend the base class has every event checked. An entity form that loaded its entity before the updater committed and saves after it overwrites the updater's write, which is core's form concurrency. And Gone is answered before access, so an actor without access learns that an identity names nothing. That order is kept after review: there is nothing to ask access of when the host, the translation or the item is not there, entity ids can be enumerated anyway, core's JSON:API answers a missing entity before it checks access in the same way, and Gone is what stops an approval workflow retrying forever.
loadByUuid() costs one indexed query per entity type and field carrying a blocknote_document field, one more on the search across revisions where a host was found, since a second host cannot show up behind the first host's own revisions, and misses a document living only in a pending revision unless the caller asks for pending revisions.
The updater is the only path with base-revision and hash checking; a form save has core's changed-time check and a person in front of it, so there are two write paths, converging only at the entity hooks and the constraint. The identity payload is public API in three places at once, so adding a member means adding it to toArray(), to the TypeScript interface and to whatever an external store persisted.
References¶
- Drupal core 11.4.6:
SqlContentEntityStorage,ContentEntityStorageBase,TransactionManagerBase,CacheTagsChecksumTrait,DatabaseLockBackend,EntityRepository. - Core issue #3447097, a post-transaction callback that starts a transaction.
src/Document/(DocumentIdentity,DocumentHasher,StoredDocument,DocumentRepository,DocumentUpdater,HostRowLock,ChangeOriginTracker,DocumentChangeDetector),src/Event/,src/Hook/BlockNoteDocumentEventHooks.php,src/Hook/BlockNoteDocumentHostLockHooks.php,js/src/events.ts,docs/workflows.md.- 0001, 0007.