Problem/Motivation
Patternkit 9.1.2 performs no JSON Schema validation of block content at render time or save time. Content saved through the off-canvas Layout Builder before Patternkit 9.1.3 may contain values that do not conform to the current pattern schema. This content accumulated silently because client-side validation was bypassed on every off-canvas save (see #3577380: Fix beforeSubmit form value lookup in patternkit.jsoneditor.js).
Sites upgrading to 9.1.3 need a way to:
- Know which blocks contain invalid content, without requiring a full-corpus scan before going live.
- Block new invalid saves at the form level, with a configurable opt-out for operators who need time to remediate legacy content.
- Record validity state in a queryable table so downstream features (render-time policy, admin dashboard) can act on known stamp state rather than re-validating on every render.
This issue delivers all of these:
- The
patternkit_block_validitydatabase table andValidityStampWriterservice - Save-path integration that writes a stamp on every published block save
- An admin-configurable enforcement toggle that gates invalid saves by default while allowing an explicit opt-out
- A cron-driven queue worker that incrementally backstamps pre-existing blocks and keeps stamps current after pattern schema updates
Steps to reproduce
None. This is a new feature, not a bug report.
Proposed resolution
Four coordinated changes land together in this MR:
1. Validity stamp table and ValidityStampWriter service
A new patternkit_block_validity table is created via hook_schema() and an idempotent hook_update_N(). The table stores one row per published block translation, keyed on (block_entity_id, langcode).
A new injectable ValidityStampWriter service (implementing ValidityStampWriterInterface) provides:
writeStamp(): idempotent upsert keyed on(block_entity_id, langcode)deleteStamp(): removes one or all language stamps for a block entitygetStamp(): loads a single stamp rowgetUnstampedBlockTranslations(int $limit): returns published, default-revision block translations that do not yet have a stamp rowgetStaleStampBlockTranslations(int $limit, int $offset): returns block translations whose stamp'spattern_revision_idno longer matches the pattern's current default revision; used by the cron re-processing phase
2. Save-time stamp integration
ValidityStampWriter is wired into the block save flow via PatternkitBlockEntityHooks. On every published (default-revision) block save:
- The block content is validated against the block's pinned
pattern_revision_id(from plugin configuration). - Token presence is detected with
Token::scan(). - For Layout Builder saves, the stamp write is deferred to
hook_entity_insert/hook_entity_updateusing a request-scopedValidityStampPendingStore, ensuring the stamp is written only after the parent entity (node, etc.) has fully persisted. Config block saves stamp inline. - The stamp is written unconditionally. Both valid and invalid saves produce a row (always-write semantics).
- Draft revisions are skipped without error.
- Stamps are deleted on block entity delete, on block removal from a layout, and on host entity delete.
3. Save-time validation enforcement toggle
A new render_validation_on_save config key (default: TRUE) and a corresponding checkbox on the Patternkit admin settings form control whether invalid block configuration is blocked at save time. When enforcement is on (default), an invalid save presents field-level error messages and does not persist. When enforcement is off, the save proceeds and the stamp records is_valid = false, building an inventory of invalid content for later remediation.
Enforcement runs on the validation result directly, before any stamp write. A stamp-write failure cannot affect the enforcement decision. Blocked saves produce no stamp row.
Stamp writes occur regardless of the toggle setting, so operators who disable enforcement retain full visibility into which blocks contain invalid content.
A hook_update_N() sets render_validation_on_save = TRUE on upgrade from Patternkit 9.1.2 or earlier. The hook is idempotent.
4. Cron-driven queue worker for incremental background scanning
A ValidityStampQueueWorker plugin processes one (block_entity_id, langcode) pair per queue item: loads the published block, resolves the current default pattern revision, validates content, and writes a stamp with stamp_source = 'cron_scan'. A missing entity, translation, or pattern is logged and the item is discarded without retries.
ValidityStampCronHooks implements hook_cron with a two-phase enqueue strategy:
- Phase 1 (unstamped): Enqueues published block translations with no stamp row at all. These blocks existed before Patternkit 9.1.3 and have never been validated.
- Phase 2 (stale): Runs only when the Phase 1 backlog is empty. Enqueues block translations whose stamp's
pattern_revision_idno longer matches the pattern's current default revision, meaning the pattern schema was updated after the block was last stamped.
A backlog guard (default: 50 items) prevents queue growth when cron fires faster than the worker drains. Cursor-based pagination allows Phase 2 to cycle through the full stamped population across multiple cron runs. Blocks whose referenced pattern entity no longer exists are logged separately as unresolvable and do not stall the cursor.
Remaining tasks
- Review and test the merge request.
User interface changes
The Patternkit admin settings form gains a checkbox: Prevent saving invalid pattern content (checked by default, in the Advanced Settings section). Help text explains that unchecking allows invalid saves while still recording validity state for remediation, and notes that the JSON Editor's client-side validation layer is independent of this server-side toggle.
When save-time enforcement is enabled and an editor submits a block with content that fails schema validation, the form returns field-level error messages instead of saving, matching the behavior editors already see from client-side validation.
Introduced terminology
Validity stamp: A row in patternkit_block_validity recording the validity state, schema revision, and token presence for a specific block translation at the time of its last published save or background scan.
Stamp source: The context in which a stamp was written. save means the stamp was written at the block save boundary. cron_scan means it was written by the background queue worker included in this issue.
API changes
New injectable service
patternkit.validity_stamp_writer (Drupal\patternkit\Validation\ValidityStampWriter, implementing Drupal\patternkit\Validation\ValidityStampWriterInterface):
writeStamp(int $blockEntityId, string $langcode, int $blockRevisionId, int $patternRevisionId, bool $isValid, bool $hasTokens, string $source = 'save', ?string $componentUuid = NULL): voiddeleteStamp(int $blockEntityId, ?string $langcode = NULL): void(NULLremoves all translations)getStamp(int $blockEntityId, string $langcode): ?arraygetUnstampedBlockTranslations(int $limit = 50): arraygetStaleStampBlockTranslations(int $limit = 50, int $offset = 0): array
Constants on ValidityStampWriterInterface:
STAMP_SOURCE_SAVE = 'save'STAMP_SOURCE_CRON = 'cron_scan'
New internal service
patternkit.validity_stamp_pending_store (Drupal\patternkit\Validation\ValidityStampPendingStore): request-scoped store for deferred stamp decisions between presave and post-save hooks. Internal to the save pipeline; external callers do not interact with it directly.
New config key
patternkit.settings → render_validation_on_save (boolean, default TRUE).
Data model changes
New database table: patternkit_block_validity
| Column | Type | Notes |
|---|---|---|
block_entity_id |
int unsigned, NOT NULL | Primary key (composite with langcode) |
langcode |
varchar_ascii(12), NOT NULL | Primary key (composite with block_entity_id) |
block_revision_id |
int unsigned, NOT NULL | Block revision at validation time |
pattern_revision_id |
int unsigned, NOT NULL | Pattern entity revision validated against |
is_valid |
tinyint(1), NOT NULL | 1 if content passed validation, 0 otherwise |
has_tokens |
tinyint(1), NOT NULL | 1 if Drupal token syntax detected in block config values |
stamp_source |
varchar_ascii(32), NOT NULL | save or cron_scan |
component_uuid |
varchar_ascii(128), NULL | Layout Builder SectionComponent UUID when available |
validated_at |
int, NOT NULL | Unix timestamp of stamp creation or last update |
Indexes: block_validity_is_valid on (is_valid), block_validity_pattern_revision on (pattern_revision_id), block_validity_component_uuid on (component_uuid).
An idempotent hook_update_N() safely creates the table on upgrade from Patternkit 9.1.2 or earlier.
Module uninstall
The patternkit_block_validity table is dropped on module uninstall.
Release notes snippet
Patternkit 9.1.3 introduces save-time block content validation. When a content editor saves a block whose configuration does not conform to the current pattern schema, the save is blocked and field-level errors are displayed, matching the behavior editors already see from client-side validation. A new patternkit_block_validity table records the validity state, schema revision, and token presence for every published block save, providing site-wide visibility into block validity state without requiring a full-corpus scan.
Site administrators can disable save-time blocking via the Patternkit settings form (Prevent saving invalid pattern content checkbox) as a deliberate opt-out for sites managing a corpus of pre-existing invalid content. Validity stamps continue to accumulate regardless of the toggle, so operators retain full visibility into which blocks need remediation even while blocking is disabled.
A cron-driven queue worker incrementally validates blocks that existed before Patternkit 9.1.3 and re-validates blocks whose pattern schema has been updated since the block was last saved. This requires no manual intervention; stamps accumulate across cron runs.
Save-time enforcement is enabled by default on fresh installs and upgrades from Patternkit 9.1.2 or earlier. Sites that have already explicitly disabled the setting are unaffected by the default. Drupal tokens in format-constrained fields (URI, email, date-time) do not trigger false validation errors. Token-containing values are detected and flagged in the stamp rather than blocking the save. This requires the token bypass from #3577380: Fix beforeSubmit form value lookup in patternkit.jsoneditor.js to be in place.
Known issues
Disabling save-time enforcement does not bypass client-side JSON Editor validation. The Prevent saving invalid pattern content checkbox controls the server-side enforcement gate only. The JSON Editor's client-side validation runs independently in the browser and intercepts invalid input before the form is submitted, regardless of the server-side toggle state. Editors who attempt to save a block with content that fails client-side validation will see a form error and the submission will not reach the server, even when the toggle is off.
The opt-out toggle is most relevant for programmatic save paths (migrations, Drush commands, or custom code calling $entity->save() directly) rather than browser-based editorial workflows where the JSON Editor is the primary entry point. Sites on Patternkit 9.1.2 or earlier may have been able to submit invalid content through the browser because the bug fixed in #3577380: Fix beforeSubmit form value lookup in patternkit.jsoneditor.js caused client-side validation to be silently bypassed on every off-canvas Layout Builder save. That bypass is now corrected; browser-submitted saves pass through client-side validation before reaching the server-side enforcement gate.
Issue fork patternkit-3589846
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #4
sluceroImplementation is complete and the branch is ready for review. All features described in the original issue are done and available on MR !191 (
3589846-add-validity-stamp-foundation). CI passed on both Drupal 10 and Drupal 11. This comment documents scope additions relative to the original issue description, implementation decisions with API-consumer consequences, data model corrections, and a bug found and fixed during development.Scope addition: cron-driven queue worker for incremental background scanning
The original description noted that
cron_scanstamps would come from a background queue worker described as a "separate issue." That work has been folded into this MR for two reasons:ValidityStampPendingStore) established here defines the timing semantics the cron worker inherits. Both decisions were load-bearing for each other and had to be finalized together.The cron implementation adds:
ValidityStampQueueWorker: processes one(block_entity_id, langcode)pair per queue item. Loads the published block, resolves the current default pattern revision, validates content, and writes a stamp withstamp_source = 'cron_scan'. A missing entity, translation, or pattern is logged and the item is discarded without retries.ValidityStampCronHooks: implementshook_cronwith a two-phase strategy. Phase 1 enqueues blocks with no stamp row at all (priority). Phase 2 enqueues blocks whose stamp'spattern_revision_idno longer matches the current default revision (stale re-processing). Phase 2 only runs when the Phase 1 backlog is empty. A configurable backlog guard (default: 50 items) prevents queue growth when cron fires faster than the worker drains.getStaleStampBlockTranslations()added toValidityStampWriterInterface: returnsstale,unresolvable, andcandidates_examinedkeys. Theunresolvableset surfaces blocks whose referenced pattern entity no longer exists, allowing operators to identify orphaned block content.Scope addition: post-save stamp boundary
Save-time stamps for Layout Builder blocks are deferred from
entity_presavetohook_entity_insert/hook_entity_updateusing a request-scopedValidityStampPendingStore. This corrects a timing problem specific to the Layout Builder path: block content is staged as a serialized draft during the block form submit and becomes effective only after the full parent entity save completes. A stamp written at presave captures state that may not have fully persisted, which would give the cron worker and future admin dashboard rows representing content that never became effective. With the deferred boundary, a stamp is only written after persistence is confirmed. Config block saves (BlockInterface) continue to stamp inline since no parent entity post-save hook applies.Scope addition:
ValidityStampResultvalue objectCarries
isValid,blockEntityId,langcode,blockRevisionId, andpatternRevisionIdthrough the validation, enforcement, and stamp pipeline. Eliminates the prior pattern of passing$configurationarrays and performing redundantloadRevision()calls at multiple points in the call chain.API corrections
The
writeStamp()signature published in the original issue description was a draft. The final interface signature is:writeStamp(int $blockEntityId, string $langcode, int $blockRevisionId, int $patternRevisionId, bool $isValid, bool $hasTokens, string $source = 'save', ?string $componentUuid = NULL): voidThe
$blockRevisionIdparameter was added. The table records the specific revision validated so downstream consumers can detect stale stamp rows before the cron worker re-processes them.deleteStamp()now accepts a nullable$langcode. WhenNULL, all stamps for that entity are removed. Used byValidityStampEntityHookson full entity delete.Data model corrections
The
stamp_sourcecolumn was described asvarchar(16)in the original issue. The actual column isvarchar_ascii(32)to leave room for future source identifiers beyondsaveandcron_scan.The
block_revision_idcolumn was omitted from the original data model table. The actual table includes it as a non-nullable unsigned int. The issue description has been updated to reflect the full column set.Three indexes were added to support expected query patterns:
block_validity_is_validon(is_valid): supports dashboard queries filtering by validity stateblock_validity_pattern_revisionon(pattern_revision_id): supports the Phase 2 stale-stamp joinblock_validity_component_uuidon(component_uuid): supports component-based lookup when Layout Builder context is availableBug corrected during CI:
patternkit_examplemedia component schemasThe four example media schemas (
media_image_embed,media_video_embed,media_combined_embed,media_wrapper) listed a hidden readonly self-referencenamefield in theirrequiredarrays. The JSON Editor never submits these hidden fields; saves had been passing only because client-side validation was bypassed by the bug fixed in #3577380: Fix beforeSubmit form value lookup in patternkit.jsoneditor.js. With save-time enforcement correctly applied for the first time, these saves were blocked. Thenamefield has been removed fromrequiredin all four schemas. This is a data model correction in the example module, not a change to enforcement behavior.Test coverage
ValidityStampTableTest: table schema, update hook idempotencyValidityStampWriterKernelTest:ValidityStampWriterCRUD and upsert contractValidityStampWriterUnstampedPublishedKernelTest:getUnstampedBlockTranslations()filters draft revisions and returns unstamped published rowsValidityStampSaveTimeKernelTest: post-save stamp boundary;is_validandhas_tokenscorrect per content stateValidityStampSavePathKernelTest: full enforcement pipeline with real entity saves covering toggle on with valid content, toggle on with invalid content (no stamp row written), and toggle off with invalid content (is_valid=0written)ValidityStampOrphanCleanupKernelTest: orphan cleanup on block entity delete and translation deleteValidityStampCronEnqueueKernelTest: cron enqueue logic, backlog guard, Phase 1 and Phase 2 phasingValidityStampQueueWorkerKernelTest: queue worker validates and stamps; handles missing entity, translation, and pattern gracefullyValidityStampEntityHooksTest(unit): hook delegation correctnessValidityStampSaveBlockComponentTest(unit): enforcement gate, deferred vs inline stamp write path, stamp-write failure is non-fatalThis change depends on #3577380: Fix beforeSubmit form value lookup in patternkit.jsoneditor.js, which fixed the client-side validation bypass in
patternkit.jsoneditor.js. Without that fix, token-bypass behavior for format-constrained fields would produce false enforcement blocks on blocks containing Drupal tokens in URI, email, or date-time fields.Comment #8
sluceroThis has now been merged for inclusion in the 9.1.3 release.
See #3542304: Patternkit 9.1.3 Release Plan.