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:

  1. Know which blocks contain invalid content, without requiring a full-corpus scan before going live.
  2. Block new invalid saves at the form level, with a configurable opt-out for operators who need time to remediate legacy content.
  3. 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_validity database table and ValidityStampWriter service
  • 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 entity
  • getStamp(): loads a single stamp row
  • getUnstampedBlockTranslations(int $limit): returns published, default-revision block translations that do not yet have a stamp row
  • getStaleStampBlockTranslations(int $limit, int $offset): returns block translations whose stamp's pattern_revision_id no 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_update using a request-scoped ValidityStampPendingStore, 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_id no 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): void
  • deleteStamp(int $blockEntityId, ?string $langcode = NULL): void (NULL removes all translations)
  • getStamp(int $blockEntityId, string $langcode): ?array
  • getUnstampedBlockTranslations(int $limit = 50): array
  • getStaleStampBlockTranslations(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.settingsrender_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

Command icon 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

slucero created an issue. See original summary.

slucero changed the visibility of the branch 3589846-add-validity-stamp-save-time-write to hidden.

slucero’s picture

Issue summary: View changes
Status: Active » Needs review
Related issues: +#3577380: Fix beforeSubmit form value lookup in patternkit.jsoneditor.js

Implementation 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_scan stamps would come from a background queue worker described as a "separate issue." That work has been folded into this MR for two reasons:

  • The post-save stamp boundary (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.
  • Shipping the table and save-path integration without a backfill path would leave all blocks that existed before Patternkit 9.1.3 without stamp rows until a future release, defeating the stated goal of site-wide visibility without a full-corpus scan.

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 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 strategy. Phase 1 enqueues blocks with no stamp row at all (priority). Phase 2 enqueues blocks whose stamp's pattern_revision_id no 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 to ValidityStampWriterInterface: returns stale, unresolvable, and candidates_examined keys. The unresolvable set 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_presave to hook_entity_insert / hook_entity_update using a request-scoped ValidityStampPendingStore. 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: ValidityStampResult value object

Carries isValid, blockEntityId, langcode, blockRevisionId, and patternRevisionId through the validation, enforcement, and stamp pipeline. Eliminates the prior pattern of passing $configuration arrays and performing redundant loadRevision() 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): void

The $blockRevisionId parameter 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. When NULL, all stamps for that entity are removed. Used by ValidityStampEntityHooks on full entity delete.

Data model corrections

The stamp_source column was described as varchar(16) in the original issue. The actual column is varchar_ascii(32) to leave room for future source identifiers beyond save and cron_scan.

The block_revision_id column 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_valid on (is_valid): supports dashboard queries filtering by validity state
  • block_validity_pattern_revision on (pattern_revision_id): supports the Phase 2 stale-stamp join
  • block_validity_component_uuid on (component_uuid): supports component-based lookup when Layout Builder context is available

Bug corrected during CI: patternkit_example media component schemas

The four example media schemas (media_image_embed, media_video_embed, media_combined_embed, media_wrapper) listed a hidden readonly self-reference name field in their required arrays. 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. The name field has been removed from required in 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 idempotency
  • ValidityStampWriterKernelTest: ValidityStampWriter CRUD and upsert contract
  • ValidityStampWriterUnstampedPublishedKernelTest: getUnstampedBlockTranslations() filters draft revisions and returns unstamped published rows
  • ValidityStampSaveTimeKernelTest: post-save stamp boundary; is_valid and has_tokens correct per content state
  • ValidityStampSavePathKernelTest: 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=0 written)
  • ValidityStampOrphanCleanupKernelTest: orphan cleanup on block entity delete and translation delete
  • ValidityStampCronEnqueueKernelTest: cron enqueue logic, backlog guard, Phase 1 and Phase 2 phasing
  • ValidityStampQueueWorkerKernelTest: queue worker validates and stamps; handles missing entity, translation, and pattern gracefully
  • ValidityStampEntityHooksTest (unit): hook delegation correctness
  • ValidityStampSaveBlockComponentTest (unit): enforcement gate, deferred vs inline stamp write path, stamp-write failure is non-fatal

This 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.

  • d9ef9593 committed on 9.1.x
    feat: #3589846 Save-time validation enforcement, stamp infrastructure,...

  • ef9dc8c1 committed on 9.1.x
    Revert accidental push of unreviewed feature work to 9.1.x
    
    Reverts...

  • slucero committed c51dc472 on 9.1.x
    feat: #3589846 Add validity stamp infrastructure, save-time integration...
slucero’s picture

Status: Needs review » Fixed

This has now been merged for inclusion in the 9.1.3 release.

See #3542304: Patternkit 9.1.3 Release Plan.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.