Commit 3afed03f667 for woocommerce

commit 3afed03f667e5c3d9bdae2deb048dccd56690583
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Tue Sep 15 15:25:22 2026 +0200

    Add JSON Schema validation to date fields. (#68143)

    * Move per-type value conversion behind the field type classes

    Adds to_document_value(), from_storage() and prepare_value_schema() to
    AbstractFieldType, and moves the checkbox, select and date special cases out
    of CheckoutFieldsStorage and AbstractAddressSchema into the types themselves.

    * Compare date field values as numbers in checkout field rules

    Date values are converted to YYYYMMDD integers on both sides before rules are
    evaluated, so numeric keywords can order one date against another. The meta
    schema is also widened to accept $data references, matching what ajv allows on
    the client, and DocumentObject::get_data() is memoized since rule evaluation
    calls it once per rule.

    * Memoize resolved date constraints and tidy the date input styles

    Duration constraints are re-resolved on every checkout render, so results are
    cached keyed by today's date. The native Temporal path is dropped in favour of
    the polyfill only, the date icon position becomes a shared mixin, and a useMemo
    that read a ref is removed.

    * Add tests for date field schema validation

    Covers registering a rule that references another field with $data, the
    YYYYMMDD conversion each location goes through, and ordering one date field
    against another end to end.

    build_meta_schema() now falls back to the pristine draft-07 schema if the file
    cannot be read or re-encoded, which also clears its phpstan baseline entry.

    * Trim the date field schema validation tests

    Drops the sweep over every $data-capable keyword down to one per value shape,
    merges the two blank-date cases into the one test that shows the asymmetry, and
    removes the prepare_values_for_document_object tests already covered end to end
    by the document object tests.

    * Wrap the date input active check to satisfy prettier

    * Fix the date field REST schema test expectation

    * Add changelog entry for the date field schema test fix

    * Use date format limits for checkout field validation

    * Update changelog for date format validation

    * Match AJV validation for empty date references

    * Add date schema defaults and clearer registration errors

    * Update changelog for date schema validation

    * Remove redundant date schema test changelog

    * Document date field schema validation

    * Fix date field focus and address review feedback

    * Simplify date field validation changelog

    * Use shared field schema helper in CheckoutSchema

diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
index 8cae00c94ab..3eec33cbe14 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
@@ -252,6 +252,8 @@ Text fields don't have any additional options beyond the general options listed

 #### Options for `date` fields

+Date field values are strings in `YYYY-MM-DD` format. In JSON Schema, use `type: string` and `format: date`. You can use the field's `validation` option to [validate dates and compare them with other date fields](#date-field-validation).
+
 As well as the options above, date fields support `min` and `max` options to limit the range of dates a shopper can pick.

 | Option name | Description | Required? | Example | Default value |
@@ -1032,6 +1034,49 @@ Validation can also be against other fields, for example, an alternative email f

 In the example above, we used [format keyword](https://github.com/ajv-validator/ajv-formats) and `$data` to refer to the current field value via [JSON pointers](https://ajv.js.org/guide/combining-schemas.html#data-reference). We also used the `errorMessage` property to provide a custom error message.

+##### Date field validation
+
+A field registered with `'type' => 'date'` holds a string. Its validation schema uses `'type' => 'string'` and `'format' => 'date'` to check for a real calendar date in `YYYY-MM-DD` format.
+
+WooCommerce supports the [AJV date comparison keywords](https://ajv.js.org/packages/ajv-formats.html#keywords-to-compare-values-formatmaximum-formatminimum-and-formatexclusivemaximum-formatexclusiveminimum):
+
+- `formatMinimum`: on or after the given date.
+- `formatMaximum`: on or before the given date.
+- `formatExclusiveMinimum`: after the given date.
+- `formatExclusiveMaximum`: before the given date.
+
+Each bound accepts a date string or a `$data` reference to another field. For example, register these fields on `woocommerce_init` to require check-out to be after check-in:
+
+```php
+woocommerce_register_additional_checkout_field(
+	array(
+		'id'       => 'hotel/check-in',
+		'label'    => 'Check-in',
+		'location' => 'order',
+		'type'     => 'date',
+		'required' => true,
+	)
+);
+
+woocommerce_register_additional_checkout_field(
+	array(
+		'id'         => 'hotel/check-out',
+		'label'      => 'Check-out',
+		'location'   => 'order',
+		'type'       => 'date',
+		'required'   => true,
+		'validation' => array(
+			'type'                   => 'string',
+			'format'                 => 'date',
+			'formatExclusiveMinimum' => array( '$data' => '1/hotel~1check-in' ),
+			'errorMessage'           => 'Check-out must be after check-in.',
+		),
+	)
+);
+```
+
+Schema validation is useful when referencing another field (via $data) or when combining conditions (AnyOf, AllOf, OneOf). If you’re validating against a static date or a relative duration, use [min/max](#options-for-date-fields), which also constrain the calendar to the specified range.
+
 #### `$data` keyword and JSON pointers

 `$data` keyword is a way in JSON schema to reference another field's value. In the above example, we use it to refer to the billing email via [JSON pointers](https://ajv.js.org/guide/combining-schemas.html#data-reference).
@@ -1049,6 +1094,7 @@ We support [JSON Schema Draft-07](https://json-schema.org/draft-07), which is si

 - `errorMessage`: Custom error message for validation, in AJV, this is `errorMessage` and in Opis, this is `$error`, we only support `errorMessage` and maps that internally for Opis. We also don't support templates in `errorMessage` for now.
 - `$data`: Refers to the current field value via [JSON pointers](https://ajv.js.org/guide/combining-schemas.html#data-reference), both Opis and AJV use the same implementation.
+- `formatMinimum`, `formatMaximum`, `formatExclusiveMinimum`, and `formatExclusiveMaximum`: Compare date strings using the [date field validation rules](#date-field-validation).


 ### Evaluation Logic
diff --git a/plugins/woocommerce/changelog/wooplug-6456-date-field-schema-validation b/plugins/woocommerce/changelog/wooplug-6456-date-field-schema-validation
new file mode 100644
index 00000000000..a6a0e086c1d
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6456-date-field-schema-validation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Allow additional date fields in Checkout block to use JSON Schema in validation.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts
index 4fa6d5b853d..2767466692b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts
@@ -5,35 +5,11 @@ import { date as formatDate } from '@wordpress/date';
 import * as DurationFns from 'temporal-polyfill/fns/Duration';
 import * as PlainDateFns from 'temporal-polyfill/fns/PlainDate';

-// Remove once TypeScript's lib ships Temporal types.
-/* eslint-disable @typescript-eslint/naming-convention -- Temporal's own class names. */
-declare const Temporal:
-	| {
-			PlainDate: {
-				from: ( date: string ) => {
-					add: ( duration: unknown ) => { toString: () => string };
-				};
-			};
-			Duration: { from: ( duration: string ) => unknown };
-	  }
-	| undefined;
-/* eslint-enable @typescript-eslint/naming-convention */
-
 /**
  * Resolves an ISO 8601-2 duration to a date relative to today.
  * We use the store timezone (from wp.date.date) so dates match between server and client.
- *
- * Uses native `Temporal` where available, falling back to the polyfill.
  */
-const resolveDuration = ( duration: string ): string => {
-	const today = formatDate( 'Y-m-d', new Date() );
-
-	if ( typeof Temporal !== 'undefined' ) {
-		return Temporal.PlainDate.from( today )
-			.add( Temporal.Duration.from( duration ) )
-			.toString();
-	}
-
+const resolveDuration = ( duration: string, today: string ): string => {
 	const [ year, month, day ] = today.split( '-' ).map( Number );

 	return PlainDateFns.toString(
@@ -44,6 +20,10 @@ const resolveDuration = ( duration: string ): string => {
 	);
 };

+// Constraints are re-resolved on every checkout render, so results are memoized. Today's date is
+// part of the key so a session crossing midnight still follows the clock.
+const resolvedDurations = new Map< string, string | undefined >();
+
 /**
  * Resolves a date field's min/max constraint to a YYYY-MM-DD value for a date input.
  *
@@ -67,11 +47,22 @@ export const resolveDateConstraint = (
 		return value;
 	}

-	try {
-		return resolveDuration( value );
-	} catch {
-		return undefined;
+	const today = formatDate( 'Y-m-d', new Date() );
+	const cacheKey = `${ value }|${ today }`;
+
+	if ( ! resolvedDurations.has( cacheKey ) ) {
+		let resolved: string | undefined;
+
+		try {
+			resolved = resolveDuration( value, today );
+		} catch {
+			resolved = undefined;
+		}
+
+		resolvedDurations.set( cacheKey, resolved );
 	}
+
+	return resolvedDurations.get( cacheKey );
 };

 /**
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/hooks/use-schema-parser.ts b/plugins/woocommerce/client/blocks/assets/js/base/hooks/use-schema-parser.ts
index 7f76d843cdd..13e7f00026d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/hooks/use-schema-parser.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/hooks/use-schema-parser.ts
@@ -106,38 +106,24 @@ const useDocumentObject = < T extends FormType | 'global' >(
 			checkout: {
 				createAccount: shouldCreateAccount,
 				customerNote: orderNotes,
-				additionalFields: Object.entries( additionalFields ).reduce(
-					( acc, [ key, value ] ) => {
-						if (
-							ORDER_FORM_KEYS.includes(
-								key as keyof OrderFormValues
-							)
-						) {
-							acc[ key as keyof OrderFormValues ] = value;
-						}
-						return acc;
-					},
-					{} as OrderFormValues
-				),
+				additionalFields: Object.fromEntries(
+					Object.entries( additionalFields ).filter( ( [ key ] ) =>
+						ORDER_FORM_KEYS.includes( key as keyof OrderFormValues )
+					)
+				) as OrderFormValues,
 				paymentMethod: activePaymentMethod,
 			},
 			customer: {
 				id: customerId,
 				billingAddress,
 				shippingAddress,
-				additionalFields: Object.entries( additionalFields ).reduce(
-					( acc, [ key, value ] ) => {
-						if (
-							CONTACT_FORM_KEYS.includes(
-								key as keyof ContactFormValues
-							)
-						) {
-							acc[ key as keyof ContactFormValues ] = value;
-						}
-						return acc;
-					},
-					{} as ContactFormValues
-				),
+				additionalFields: Object.fromEntries(
+					Object.entries( additionalFields ).filter( ( [ key ] ) =>
+						CONTACT_FORM_KEYS.includes(
+							key as keyof ContactFormValues
+						)
+					)
+				) as ContactFormValues,
 				...( formType === 'billing' || formType === 'shipping'
 					? {
 							address:
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/schema-parser/index.ts b/plugins/woocommerce/client/blocks/assets/js/utils/schema-parser/index.ts
index 8fab4130532..127f2a15ce1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/utils/schema-parser/index.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/schema-parser/index.ts
@@ -23,9 +23,11 @@ ajv.addFormat(

 addFormats( ajv, {
 	mode: 'fast',
-	formats: [ 'date', 'time', 'uri' ],
+	formats: [ 'time', 'uri' ],
 	keywords: true,
 } );
+// Date rules must reject impossible calendar dates as the server does.
+addFormats( ajv, { mode: 'full', formats: [ 'date' ], keywords: false } );
 addErrors( ajv );

 // Add type declaration for window.schemaParser
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/schema-parser/test/date-format.ts b/plugins/woocommerce/client/blocks/assets/js/utils/schema-parser/test/date-format.ts
new file mode 100644
index 00000000000..241f2fa7a7c
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/schema-parser/test/date-format.ts
@@ -0,0 +1,65 @@
+/**
+ * Internal dependencies
+ */
+import { schemaParser } from '../index';
+
+describe( 'Date schema comparisons', () => {
+	it.each( [
+		[ 'formatMinimum', '2026-05-01', false ],
+		[ 'formatMinimum', '2026-05-02', true ],
+		[ 'formatMinimum', '2026-05-03', true ],
+		[ 'formatMaximum', '2026-05-01', true ],
+		[ 'formatMaximum', '2026-05-02', true ],
+		[ 'formatMaximum', '2026-05-03', false ],
+		[ 'formatExclusiveMinimum', '2026-05-01', false ],
+		[ 'formatExclusiveMinimum', '2026-05-02', false ],
+		[ 'formatExclusiveMinimum', '2026-05-03', true ],
+		[ 'formatExclusiveMaximum', '2026-05-01', true ],
+		[ 'formatExclusiveMaximum', '2026-05-02', false ],
+		[ 'formatExclusiveMaximum', '2026-05-03', false ],
+		[ 'formatMaximum', '2026-02-30', false ],
+		[ 'formatMaximum', '2026--02--01', false ],
+		[ 'formatMaximum', '2026/02/01', false ],
+	] )( '%s validates %s as %s', ( keyword, value, expected ) => {
+		for ( const limit of [
+			'2026-05-02',
+			{ $data: '1/hotel~1reference' },
+			{ $data: '/hotel~1reference' },
+		] ) {
+			const validate = schemaParser.compile( {
+				type: 'object',
+				properties: {
+					date: {
+						type: 'string',
+						format: 'date',
+						[ keyword ]: limit,
+					},
+				},
+			} );
+			expect(
+				validate( { date: value, 'hotel/reference': '2026-05-02' } )
+			).toBe( expected );
+		}
+	} );
+
+	it.each( [
+		[ {}, true ],
+		[ { reference: '' }, true ],
+		[ { reference: null }, false ],
+		[ { reference: 20260501 }, false ],
+		[ { reference: false }, false ],
+	] )( 'handles reference values %j', ( values, expected ) => {
+		const validate = schemaParser.compile( {
+			type: 'object',
+			properties: {
+				date: {
+					format: 'date',
+					formatMinimum: { $data: '1/reference' },
+				},
+			},
+		} );
+		expect( validate( { ...values, date: '2026-05-02' } ) ).toBe(
+			expected
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/style.scss b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/style.scss
index 49bad1ba6fa..ed1c8240ac2 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/style.scss
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/style.scss
@@ -57,7 +57,7 @@

 		height: 50px;

-		&:focus {
+		&:focus-within {
 			background-color: $input-background-light;
 			color: $input-text-light;
 			border: 1.5px solid currentColor;
@@ -68,7 +68,7 @@
 			border-color: $input-border-dark;
 			color: $input-text-dark;

-			&:focus {
+			&:focus-within {
 				background-color: $input-background-dark;
 				color: $input-text-dark;
 				border: 1.5px solid currentColor;
@@ -78,7 +78,6 @@

 	&:not(.is-active) input[type="date"] {
 		// Hide browser default mask (dd/mm/YYYY) when a date field is not active (no value, not focused).
-		// Use text-indent here because using transparent would conflict with our calendar icon-hiding hack.
 		text-indent: -9999px;

 		&::-webkit-datetime-edit {
@@ -101,6 +100,11 @@

 	// Clean out chrome default padding on segments that makes the field visually off compared to other fields.
 	input[type="date"] {
+		// Chrome moves focus into the calendar button, so :focus no longer matches the input.
+		&:focus-within:not(:focus) {
+			outline: 1px auto -webkit-focus-ring-color;
+		}
+
 		&::-webkit-datetime-edit,
 		&::-webkit-datetime-edit-fields-wrapper {
 			padding: 0;
@@ -116,65 +120,35 @@
 		}
 	}

-	// The native webkit indicator is hidden but kept in place as the click target that opens the picker.
-	// The visible icon is our own so that we can control its position freely.
-	input[type="date"]::-webkit-calendar-picker-indicator {
+	// Keep the native calendar button and the custom icon aligned.
+	@mixin date-icon-position {
 		position: absolute;
 		top: 25px;
 		inset-inline-end: $gap-small;
 		transform: translateY(-50%);
 		width: 24px;
 		height: 24px;
+	}
+
+	// Hide only the native image so the calendar button keeps its focus ring.
+	input[type="date"]::-webkit-calendar-picker-indicator {
+		@include date-icon-position;
 		margin: 0;
 		padding: 0;
-		opacity: 0;
+		background-image: none;
 		cursor: pointer;
 	}

-	// Firefox paints its native calendar icon with currentColor and exposes no pseudo-element to hide it,
-	// so we make the color transparent (hiding the icon) and repaint the text via -webkit-text-fill-color.
-	// Both borders derive from currentColor too, so they get explicit colors instead.
-	@supports (-moz-appearance: none) {
-		input[type="date"] {
-			// $universal-border-strong is derived from currentColor, so the resting border needs its own
-			// explicit color. The dark variant already has one.
-			border-color: color-mix(in srgb, $input-text-light 80%, transparent);
-
-			&,
-			&:focus {
-				color: transparent;
-				-webkit-text-fill-color: $input-text-light;
-			}
-
-			&:focus {
-				border-color: $input-text-light;
-			}
-
-			.has-dark-controls & {
-				&,
-				&:focus {
-					color: transparent;
-					-webkit-text-fill-color: $input-text-dark;
-				}
-
-				&:focus {
-					border-color: $input-text-dark;
-				}
-			}
-		}
-	}
-
 	.wc-block-components-text-input__date-icon {
-		position: absolute;
-		top: 25px;
-		inset-inline-end: $gap-small;
-		transform: translateY(-50%);
-		width: 24px;
-		height: 24px;
-		// The icon is decorative only, and clicks should pass through to the native hidden calendar button.
+		@include date-icon-position;
+		// Let clicks reach the native calendar button.
 		pointer-events: none;
 		color: $input-text-light;

+		@supports (-moz-appearance: none) {
+			display: none;
+		}
+
 		svg {
 			display: block;
 			fill: currentColor;
@@ -206,7 +180,7 @@
 	&.is-active input[type="date"] {
 		padding: $gap-large $gap-smaller + 1px $gap-smaller;

-		&:focus {
+		&:focus-within {
 			padding-top: $gap-large;
 			padding-left: $gap-smaller + 0.5px;
 		}
@@ -222,22 +196,22 @@
 	&.has-error input {
 		&,
 		&:hover,
-		&:focus,
+		&:focus-within,
 		&:active {
 			border-color: $alert-red;
 		}
-		&:focus {
+		&:focus-within {
 			box-shadow: 0 0 0 0.5px $alert-red;
 		}

 		.has-dark-controls &,
 		.has-dark-controls &:hover,
-		.has-dark-controls &:focus,
+		.has-dark-controls &:focus-within,
 		.has-dark-controls &:active {
 			border-color: color.adjust($alert-red, $lightness: 30%);
 		}

-		.has-dark-controls &:focus {
+		.has-dark-controls &:focus-within {
 			box-shadow: 0 0 0 0.5px color.adjust($alert-red, $lightness: 30%);
 		}
 	}
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/text-input.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/text-input.tsx
index 5e20fa14b1d..c6bbbcc63ff 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/text-input.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-components/text-input/text-input.tsx
@@ -2,12 +2,7 @@
  * External dependencies
  */
 import clsx from 'clsx';
-import {
-	forwardRef,
-	isValidElement,
-	useMemo,
-	useState,
-} from '@wordpress/element';
+import { forwardRef, isValidElement, useState } from '@wordpress/element';
 import { decodeEntities } from '@wordpress/html-entities';
 import type { InputHTMLAttributes, ReactNode } from 'react';

@@ -66,11 +61,9 @@ const TextInput = forwardRef< HTMLInputElement, TextInputProps >(
 		// Date-like inputs report a value the browser can't parse (e.g. the 31st of a 30-day month) as an
 		// empty `value`, so the input is asked directly. Focus and blur both re-render, which is when this
 		// can have changed while the field is not active.
-		const isFieldActive = useMemo( () => {
-			const input = typeof ref === 'object' ? ref?.current : null;
-
-			return isActive || !! value || !! input?.validity?.badInput;
-		}, [ isActive, value, ref ] );
+		const input = typeof ref === 'object' ? ref?.current : null;
+		const isFieldActive =
+			isActive || !! value || !! input?.validity?.badInput;

 		const inputWithLabel = (
 			<>
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 8e0598372ec..6af33f889d8 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -52774,12 +52774,6 @@ parameters:
 			count: 1
 			path: src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObject.php

-		-
-			message: '#^Static property Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFieldsSchema\\Validation\:\:\$meta_schema_json \(string\) does not accept string\|false\.$#'
-			identifier: assign.propertyType
-			count: 1
-			path: src/Blocks/Domain/Services/CheckoutFieldsSchema/Validation.php
-
 		-
 			message: '#^Call to an undefined method WC_Session\:\:has_session\(\)\.$#'
 			identifier: method.notFound
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php
index 9daa9bc25e3..b9efec9995f 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php
@@ -18,6 +18,18 @@ use WP_Error;
  */
 abstract class AbstractFieldType {

+	/**
+	 * Adds type-specific defaults to a field's validation schema.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param array $schema The supplied validation schema.
+	 * @return array The schema with defaults applied.
+	 */
+	public function prepare_validation_schema( array $schema ): array {
+		return $schema;
+	}
+
 	/**
 	 * Validates the options that apply to every field type: callbacks, hidden state, and rule schemas.
 	 *
@@ -177,6 +189,29 @@ abstract class AbstractFieldType {
 		return $value;
 	}

+	/**
+	 * Converts a stored meta value into the type the rest of checkout works with.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param mixed $value The stored value.
+	 * @return mixed The converted value.
+	 */
+	public function from_storage( $value ) {
+		return $value;
+	}
+
+	/**
+	 * Applies type-specific keywords to a field's REST API value schema.
+	 *
+	 * @param array $field_schema The schema built for the field so far.
+	 * @param array $field        The field.
+	 * @return array The updated schema.
+	 */
+	public function prepare_value_schema( array $field_schema, array $field ): array {
+		return $field_schema;
+	}
+
 	/**
 	 * Applies type-specific arguments to a field before it is rendered with woocommerce_form_field().
 	 *
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
index b2233c62848..a543fbd4929 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
@@ -49,6 +49,29 @@ class CheckboxFieldType extends AbstractFieldType {
 		return $value ? __( 'Yes', 'woocommerce' ) : __( 'No', 'woocommerce' );
 	}

+	/**
+	 * Converts the stored '1'/'0' meta value back to a boolean.
+	 *
+	 * @param mixed $value The stored value.
+	 * @return bool
+	 */
+	public function from_storage( $value ) {
+		return '1' === $value;
+	}
+
+	/**
+	 * Declares checkbox values as booleans in the REST API value schema.
+	 *
+	 * @param array $field_schema The schema built for the field so far.
+	 * @param array $field        The field.
+	 * @return array The updated schema.
+	 */
+	public function prepare_value_schema( array $field_schema, array $field ): array {
+		$field_schema['type'] = 'boolean';
+
+		return $field_schema;
+	}
+
 	/**
 	 * Sets the checked and unchecked values woocommerce_form_field() should submit.
 	 *
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
index 3d6f8b5c53a..8eebb9fc99d 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
@@ -16,9 +16,35 @@ use WP_Error;
 class DateFieldType extends AbstractFieldType {

 	/**
-	 * Matches a date in YYYY-MM-DD format.
+	 * Matches a YYYY-MM-DD date with valid month and day ranges.
+	 *
+	 * Undelimited because a JSON Schema pattern takes a bare expression; the PHP uses add delimiters.
+	 */
+	private const DATE_PATTERN = '\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])';
+
+	/**
+	 * Defaults date comparison schemas to date strings without replacing supplied keywords.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param array $schema The supplied validation schema.
+	 * @return array The schema with defaults applied.
 	 */
-	private const ABSOLUTE_DATE = '/^\d{4}-\d{2}-\d{2}$/';
+	public function prepare_validation_schema( array $schema ): array {
+		foreach ( array( 'formatMinimum', 'formatMaximum', 'formatExclusiveMinimum', 'formatExclusiveMaximum' ) as $keyword ) {
+			if ( array_key_exists( $keyword, $schema ) ) {
+				return array_merge(
+					array(
+						'type'   => 'string',
+						'format' => 'date',
+					),
+					$schema
+				);
+			}
+		}
+
+		return $schema;
+	}

 	/**
 	 * Processes the options for a date field and returns the new field_options array.
@@ -315,6 +341,6 @@ class DateFieldType extends AbstractFieldType {
 	 * @return bool
 	 */
 	private function is_absolute_date( $value ): bool {
-		return is_string( $value ) && 1 === preg_match( self::ABSOLUTE_DATE, $value );
+		return is_string( $value ) && 1 === preg_match( '/^' . self::DATE_PATTERN . '$/', $value );
 	}
 }
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php
index bc263f29eeb..1443263507f 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php
@@ -64,6 +64,19 @@ class SelectFieldType extends AbstractFieldType {
 		return $options[ $value ] ?? $value;
 	}

+	/**
+	 * Restricts select values to the registered options in the REST API value schema.
+	 *
+	 * @param array $field_schema The schema built for the field so far.
+	 * @param array $field        The field.
+	 * @return array The updated schema.
+	 */
+	public function prepare_value_schema( array $field_schema, array $field ): array {
+		$field_schema['enum'] = array_column( $field['options'], 'value' );
+
+		return $field_schema;
+	}
+
 	/**
 	 * Maps the registered options to the value => label format woocommerce_form_field() expects.
 	 *
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
index c7b75ab7520..7a9420d58ff 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
@@ -453,7 +453,12 @@ class CheckoutFields {
 			return false;
 		}

-		return $this->get_field_type( $options )->validate_options( $options );
+		$field_type = $this->get_field_type( $options );
+		if ( isset( $options['validation'] ) && is_array( $options['validation'] ) ) {
+			$options['validation'] = $field_type->prepare_validation_schema( $options['validation'] );
+		}
+
+		return $field_type->validate_options( $options );
 	}

 	/**
@@ -566,20 +571,6 @@ class CheckoutFields {
 		return $field_value;
 	}

-	/**
-	 * Validates a value against the constraints of its field type.
-	 *
-	 * This runs for every field regardless of the validate_callback it was registered with, so type level
-	 * constraints cannot be bypassed by supplying a custom callback.
-	 *
-	 * @param array $field       The field.
-	 * @param mixed $field_value The value of the field.
-	 * @return WP_Error|null Error if the value is not valid for the field type, null otherwise.
-	 */
-	private function validate_field_type( $field, $field_value ) {
-		return $this->get_field_type( $field )->validate( $field_value, $field );
-	}
-
 	/**
 	 * Validate an additional field.
 	 *
@@ -598,7 +589,9 @@ class CheckoutFields {
 				return $errors;
 			}

-			$type_error = $this->validate_field_type( $field, $field_value );
+			// Type level constraints run for every field regardless of the validate_callback it was
+			// registered with, so they cannot be bypassed by supplying a custom callback.
+			$type_error = $this->get_field_type( $field )->validate( $field_value, $field );

 			if ( is_wp_error( $type_error ) ) {
 				$errors->merge_from( $type_error );
@@ -995,6 +988,17 @@ class CheckoutFields {
 		return $this->get_field_type( $form_field )->prepare_form_field( $form_field );
 	}

+	/**
+	 * Applies type-specific keywords to a field's REST API value schema.
+	 *
+	 * @param array $field_schema The schema built for the field so far.
+	 * @param array $field        The field.
+	 * @return array The updated schema.
+	 */
+	public function prepare_field_value_schema( array $field_schema, array $field ): array {
+		return $this->get_field_type( $field )->prepare_value_schema( $field_schema, $field );
+	}
+
 	/**
 	 * Prepares a group name for use.
 	 *
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObject.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObject.php
index 2bf30c84aa0..80a796aa8ca 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObject.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObject.php
@@ -75,6 +75,14 @@ class DocumentObject {
 	 */
 	protected $request_data = [];

+	/**
+	 * Memoized result of get_data(). Rule evaluation calls get_data() once per rule, so the
+	 * assembled data is cached until the cart, customer, or context changes.
+	 *
+	 * @var array|null
+	 */
+	protected $data = null;
+
 	/**
 	 * The constructor.
 	 *
@@ -96,6 +104,7 @@ class DocumentObject {
 			return;
 		}
 		$this->context = $context;
+		$this->data    = null;
 	}

 	/**
@@ -105,6 +114,7 @@ class DocumentObject {
 	 */
 	public function set_customer( WC_Customer $customer ) {
 		$this->customer = $customer;
+		$this->data     = null;
 	}

 	/**
@@ -114,6 +124,7 @@ class DocumentObject {
 	 */
 	public function set_cart( WC_Cart $cart ) {
 		$this->cart = $cart;
+		$this->data = null;
 	}

 	/**
@@ -208,6 +219,10 @@ class DocumentObject {
 	 * @return array The data for the document object.
 	 */
 	public function get_data() {
+		if ( ! is_null( $this->data ) ) {
+			return $this->data;
+		}
+
 		// Get cart and customer objects before returning data if they are null.
 		if ( is_null( $this->cart ) ) {
 			$this->cart = $this->cart_controller->get_cart_for_response();
@@ -217,11 +232,13 @@ class DocumentObject {
 			$this->customer = ! empty( WC()->customer ) ? WC()->customer : new WC_Customer();
 		}

-		return [
+		$this->data = [
 			'cart'     => $this->get_cart_data(),
 			'customer' => $this->get_customer_data(),
 			'checkout' => $this->get_checkout_data(),
 		];
+
+		return $this->data;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsSchema/Validation.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsSchema/Validation.php
index 0ea55effa8a..46c1f418570 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsSchema/Validation.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsSchema/Validation.php
@@ -4,6 +4,12 @@ declare( strict_types = 1);
 namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema;

 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
+use Automattic\WooCommerce\Internal\Checkout\DateFormatLimitParser;
+use Opis\JsonSchema\Errors\ErrorFormatter;
+use Opis\JsonSchema\Errors\ValidationError;
+use Opis\JsonSchema\Parsers\DefaultVocabulary;
+use Opis\JsonSchema\Parsers\SchemaParser;
+use Opis\JsonSchema\SchemaLoader;
 use Opis\JsonSchema\{
 	Helper,
 	Validator
@@ -21,6 +27,39 @@ class Validation {
 	 */
 	private static $meta_schema_json = '';

+	/**
+	 * Date comparison keywords shared with ajv-formats.
+	 */
+	private const FORMAT_LIMIT_KEYWORDS = [
+		'formatMinimum',
+		'formatMaximum',
+		'formatExclusiveMinimum',
+		'formatExclusiveMaximum',
+	];
+
+	/**
+	 * Keywords that may hold a `$data` reference instead of a literal value, because
+	 * the base document schema we have doesn't include those.
+	 */
+	private const DATA_REF_KEYWORDS = [
+		'multipleOf',
+		'maximum',
+		'exclusiveMaximum',
+		'minimum',
+		'exclusiveMinimum',
+		'maxLength',
+		'minLength',
+		'pattern',
+		'maxItems',
+		'minItems',
+		'uniqueItems',
+		'maxProperties',
+		'minProperties',
+		'required',
+		'enum',
+		'format',
+	];
+
 	/**
 	 * Get the field schema with context.
 	 *
@@ -90,7 +129,11 @@ class Validation {
 		}

 		try {
-			$validator = new Validator();
+			$vocabulary = new DefaultVocabulary();
+			foreach ( self::FORMAT_LIMIT_KEYWORDS as $keyword ) {
+				$vocabulary->appendKeyword( new DateFormatLimitParser( $keyword ) );
+			}
+			$validator = new Validator( new SchemaLoader( new SchemaParser( [], [], $vocabulary ) ) );
 			$result    = $validator->validate(
 				Helper::toJSON( $document_object->get_data() ),
 				Helper::toJSON( $rules )
@@ -153,8 +196,7 @@ class Validation {
 		}

 		if ( empty( self::$meta_schema_json ) ) {
-			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
-			self::$meta_schema_json = file_get_contents( __DIR__ . '/json-schema-draft-07.json' );
+			self::$meta_schema_json = self::build_meta_schema();
 		}

 		$validator = new Validator();
@@ -172,10 +214,103 @@ class Validation {
 			self::$meta_schema_json
 		);

-		if ( $result->hasError() ) {
-			return new WP_Error( 'woocommerce_rest_checkout_invalid_field_schema', esc_html( (string) $result->error() ) );
+		$error = $result->error();
+		if ( null !== $error ) {
+			return new WP_Error( 'woocommerce_rest_checkout_invalid_field_schema', self::format_schema_error( $error ) );
 		}

 		return true;
 	}
+
+	/**
+	 * Describes schema errors at the failing keyword instead of the outer schema wrapper.
+	 *
+	 * @param ValidationError $error The schema validation error.
+	 * @return string The error paths and messages.
+	 */
+	private static function format_schema_error( ValidationError $error ): string {
+		$formatter = new ErrorFormatter();
+		$errors    = $formatter->format(
+			$error,
+			true,
+			static function ( ValidationError $error ) use ( $formatter ): string {
+				if ( 'const' === $error->keyword() ) {
+					return sprintf( 'The value must be %s', wp_json_encode( $error->args()['const'] ) );
+				}
+				$schema = $error->schema()->info()->data();
+				if ( 'enum' === $error->keyword() && is_object( $schema ) && isset( $schema->enum ) ) {
+					return sprintf( 'The value must be one of: %s', implode( ', ', array_map( 'wp_json_encode', $schema->enum ) ) );
+				}
+				return $formatter->formatErrorMessage( $error );
+			}
+		);
+		$messages  = [];
+		foreach ( $errors as $path => $details ) {
+			// The properties/test prefix belongs to our meta-schema check, not the supplied rule.
+			$path       = rawurldecode( preg_replace( '#^/properties/test(?=/|$)#', '', $path ) ?? $path );
+			$messages[] = sprintf( 'At "%s": %s.', $path ? $path : '/', implode( '; ', $details ) );
+		}
+
+		return implode( ' ', $messages );
+	}
+
+	/**
+	 * Adds $data references and date comparisons to the draft-07 meta schema.
+	 *
+	 * @return string The meta schema as JSON.
+	 */
+	private static function build_meta_schema(): string {
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$draft_07    = (string) file_get_contents( __DIR__ . '/json-schema-draft-07.json' );
+		$meta_schema = json_decode( $draft_07, true );
+
+		// Fall back to the pristine meta schema rather than none at all, so rules are still checked.
+		if ( ! is_array( $meta_schema ) ) {
+			return $draft_07;
+		}
+
+		$meta_schema['definitions']['dataRef'] = [
+			'type'                 => 'object',
+			'required'             => [ '$data' ],
+			'properties'           => [
+				'$data' => [
+					'type'  => 'string',
+					'anyOf' => [
+						[ 'format' => 'json-pointer' ],
+						[ 'format' => 'relative-json-pointer' ],
+					],
+				],
+			],
+			'additionalProperties' => false,
+		];
+
+		foreach ( self::DATA_REF_KEYWORDS as $keyword ) {
+			$meta_schema['properties'][ $keyword ] = [
+				'anyOf' => [
+					$meta_schema['properties'][ $keyword ],
+					[ '$ref' => '#/definitions/dataRef' ],
+				],
+			];
+		}
+
+		foreach ( self::FORMAT_LIMIT_KEYWORDS as $keyword ) {
+			$meta_schema['properties'][ $keyword ]   = [
+				'anyOf' => [
+					[
+						'type'   => 'string',
+						'format' => 'date',
+					],
+					[ '$ref' => '#/definitions/dataRef' ],
+				],
+			];
+			$meta_schema['dependencies'][ $keyword ] = [
+				'required'   => [ 'format' ],
+				'properties' => [ 'format' => [ 'const' => 'date' ] ],
+			];
+		}
+
+		$widened = wp_json_encode( $meta_schema );
+
+		return is_string( $widened ) ? $widened : $draft_07;
+	}
 }
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
index fca1363808f..9067cd94d94 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
@@ -108,9 +108,9 @@ trait CheckoutFieldsStorage {
 			$value = apply_filters( "woocommerce_get_default_value_for_{$key}", null, $group, $wc_object );
 		}

-		// We cast the value to a boolean if the field is a checkbox.
-		if ( $this->is_field( $key ) && 'checkbox' === $this->additional_fields[ $key ]['type'] ) {
-			return '1' === $value;
+		// Let the field type convert the stored value, e.g. checkboxes cast '1'/'0' back to a boolean.
+		if ( $this->is_field( $key ) ) {
+			$value = $this->get_field_type( $this->additional_fields[ $key ] )->from_storage( $value );
 		}

 		if ( null === $value ) {
diff --git a/plugins/woocommerce/src/Internal/Checkout/DateFormatLimit.php b/plugins/woocommerce/src/Internal/Checkout/DateFormatLimit.php
new file mode 100644
index 00000000000..9fbed820757
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Checkout/DateFormatLimit.php
@@ -0,0 +1,106 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Checkout;
+
+use Opis\JsonSchema\Errors\ValidationError;
+use Opis\JsonSchema\JsonPointer;
+use Opis\JsonSchema\Keyword;
+use Opis\JsonSchema\Keywords\ErrorTrait;
+use Opis\JsonSchema\Schema;
+use Opis\JsonSchema\ValidationContext;
+
+/**
+ * Compares date strings using the ajv-formats limit keywords.
+ *
+ * @internal
+ */
+final class DateFormatLimit implements Keyword {
+
+	use ErrorTrait;
+
+	/**
+	 * The format comparison keyword.
+	 *
+	 * @var string
+	 */
+	private $keyword;
+
+	/**
+	 * The date limit or a reference to its value.
+	 *
+	 * @var string|JsonPointer
+	 */
+	private $limit;
+
+	/**
+	 * Sets the comparison and its limit.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param string             $keyword The comparison keyword.
+	 * @param string|JsonPointer $limit   The date limit or data reference.
+	 */
+	public function __construct( string $keyword, $limit ) {
+		$this->keyword = $keyword;
+		$this->limit   = $limit;
+	}
+
+	/**
+	 * Checks the date against its limit without changing either value.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param ValidationContext $context The current document and value.
+	 * @param Schema            $schema  The schema being evaluated.
+	 * @return ValidationError|null The comparison error, if any.
+	 */
+	public function validate( ValidationContext $context, Schema $schema ): ?ValidationError {
+		$limit = $this->limit instanceof JsonPointer
+			? $this->limit->data( $context->rootData(), $context->currentDataPath(), $this )
+			: $this->limit;
+
+		// AJV skips an unresolved $data reference, but rejects one with the wrong type.
+		if ( $this === $limit ) {
+			return null;
+		}
+
+		if ( ! is_string( $limit ) ) {
+			return $this->error( $schema, $context, $this->keyword, 'The date limit must be a string.' );
+		}
+
+		// ajv-formats does not compare dates when either string is empty.
+		if ( '' === $limit || '' === $context->currentData() ) {
+			return null;
+		}
+
+		$comparison = strcmp( $context->currentData(), $limit );
+		switch ( $this->keyword ) {
+			case 'formatMinimum':
+				$valid = $comparison >= 0;
+				break;
+			case 'formatMaximum':
+				$valid = $comparison <= 0;
+				break;
+			case 'formatExclusiveMinimum':
+				$valid = $comparison > 0;
+				break;
+			case 'formatExclusiveMaximum':
+				$valid = $comparison < 0;
+				break;
+			default:
+				return $this->error( $schema, $context, $this->keyword, 'Unsupported date comparison keyword.' );
+		}
+
+		return $valid ? null : $this->error(
+			$schema,
+			$context,
+			$this->keyword,
+			'Date does not satisfy {keyword}: {limit}.',
+			array(
+				'keyword' => $this->keyword,
+				'limit'   => $limit,
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Checkout/DateFormatLimitParser.php b/plugins/woocommerce/src/Internal/Checkout/DateFormatLimitParser.php
new file mode 100644
index 00000000000..1f5ce59cb67
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Checkout/DateFormatLimitParser.php
@@ -0,0 +1,69 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Checkout;
+
+use Opis\JsonSchema\Info\SchemaInfo;
+use Opis\JsonSchema\Keyword;
+use Opis\JsonSchema\Parsers\DataKeywordTrait;
+use Opis\JsonSchema\Parsers\KeywordParser;
+use Opis\JsonSchema\Parsers\SchemaParser;
+
+/**
+ * Adds date format comparisons to Opis, which does not provide them itself.
+ *
+ * @internal
+ */
+final class DateFormatLimitParser extends KeywordParser {
+
+	use DataKeywordTrait;
+
+	/**
+	 * Applies date comparisons only to string values.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return string The value type.
+	 */
+	public function type(): string {
+		return self::TYPE_STRING;
+	}
+
+	/**
+	 * Parses a literal date limit or a reference to another date.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param SchemaInfo   $info   The schema to parse.
+	 * @param SchemaParser $parser The Opis schema parser.
+	 * @param object       $shared State shared by the keyword parsers.
+	 * @return Keyword|null The date comparison, if present.
+	 * @throws \Opis\JsonSchema\Exceptions\InvalidKeywordException If the format or limit is unsupported.
+	 */
+	public function parse( SchemaInfo $info, SchemaParser $parser, object $shared ): ?Keyword {
+		unset( $shared );
+		if ( ! $this->keywordExists( $info ) ) {
+			return null;
+		}
+
+		if ( 'date' !== ( $info->data()->format ?? null ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Opis requires the schema object, which is not output.
+			throw $this->keywordException( '{keyword} requires format: date.', $info );
+		}
+
+		$value = $this->keywordValue( $info );
+		if ( $this->isDataKeywordAllowed( $parser, $this->keyword ) ) {
+			$pointer = $this->getDataKeywordPointer( $value );
+			if ( $pointer ) {
+				return new DateFormatLimit( $this->keyword, $pointer );
+			}
+		}
+
+		if ( ! is_string( $value ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Opis requires the schema object, which is not output.
+			throw $this->keywordException( '{keyword} must contain a string or a valid $data reference.', $info );
+		}
+
+		return new DateFormatLimit( $this->keyword, $value );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
index 617f2647e9a..8d0063b5bfb 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php
@@ -318,20 +318,7 @@ abstract class AbstractAddressSchema extends AbstractSchema {
 				'required'    => $this->additional_fields_controller->is_conditional_field( $field ) ? false : true === $field['required'],
 			];

-			if ( 'select' === $field['type'] ) {
-				$field_schema['enum'] = array_map(
-					function ( $option ) {
-						return $option['value'];
-					},
-					$field['options']
-				);
-			}
-
-			if ( 'checkbox' === $field['type'] ) {
-				$field_schema['type'] = 'boolean';
-			}
-
-			$schema[ $key ] = $field_schema;
+			$schema[ $key ] = $this->additional_fields_controller->prepare_field_value_schema( $field_schema, $field );
 		}
 		return $schema;
 	}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
index 7fed5edb616..e79e79634c3 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
@@ -366,20 +366,10 @@ class CheckoutSchema extends AbstractSchema {
 				'required'    => $this->additional_fields_controller->is_conditional_field( $field ) ? false : true === $field['required'],
 			];

-			if ( 'select' === $field['type'] ) {
-				$field_schema['enum'] = array_map(
-					function ( $option ) {
-						return $option['value'];
-					},
-					$field['options']
-				);
-				if ( true !== $field['required'] || $this->additional_fields_controller->is_conditional_field( $field ) ) {
-					$field_schema['enum'][] = '';
-				}
-			}
+			$field_schema = $this->additional_fields_controller->prepare_field_value_schema( $field_schema, $field );

-			if ( 'checkbox' === $field['type'] ) {
-				$field_schema['type'] = 'boolean';
+			if ( 'select' === $field['type'] && ( true !== $field['required'] || $this->additional_fields_controller->is_conditional_field( $field ) ) ) {
+				$field_schema['enum'][] = '';
 			}

 			if ( 'checkbox' === $field['type'] && true === $field['required'] ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObjectTests.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObjectTests.php
index 86a537fa772..9d6448583c7 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObjectTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/DocumentObjectTests.php
@@ -132,6 +132,9 @@ class DocumentObjectTests extends \WC_Unit_Test_Case {
 		// parent teardown does not reset, so deregister the fields unconditionally.
 		$this->additional_fields_controller->deregister_checkout_field( 'namespace/contact_field' );
 		$this->additional_fields_controller->deregister_checkout_field( 'namespace/order_field' );
+		$this->additional_fields_controller->deregister_checkout_field( 'namespace/contact_date' );
+		$this->additional_fields_controller->deregister_checkout_field( 'namespace/order_date' );
+		$this->additional_fields_controller->deregister_checkout_field( 'namespace/address_date' );

 		parent::tearDown();
 	}
@@ -299,6 +302,131 @@ class DocumentObjectTests extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Registers a date field in each of the three locations date values can reach the document object from.
+	 */
+	private function register_date_fields() {
+		foreach ( array(
+			'namespace/contact_date' => 'contact',
+			'namespace/order_date'   => 'order',
+			'namespace/address_date' => 'address',
+		) as $id => $location ) {
+			\woocommerce_register_additional_checkout_field(
+				array(
+					'id'       => $id,
+					'label'    => 'Date field',
+					'location' => $location,
+					'type'     => 'date',
+				)
+			);
+		}
+	}
+
+	/**
+	 * @testdox Date field values remain YYYY-MM-DD strings in every location.
+	 */
+	public function test_date_values_remain_strings() {
+		$this->register_date_fields();
+
+		$document_object = new DocumentObject(
+			[
+				'customer' => [
+					'additional_fields' => [ 'namespace/contact_date' => '2026-01-15' ],
+					'billing_address'   => [ 'namespace/address_date' => '2026-03-04' ],
+				],
+				'checkout' => [
+					'additional_fields' => [ 'namespace/order_date' => '2026-12-31' ],
+				],
+			]
+		);
+		$document_object->set_customer( new WC_Customer( 0 ) );
+
+		$data = $document_object->get_data();
+
+		$this->assertSame( '2026-01-15', $data['customer']['additional_fields']['namespace/contact_date'] );
+		$this->assertSame( '2026-03-04', $data['customer']['billing_address']['namespace/address_date'] );
+		$this->assertSame( '2026-12-31', $data['checkout']['additional_fields']['namespace/order_date'] );
+	}
+
+	/**
+	 * @testdox Blank and invalid dates remain unchanged for schema validation.
+	 *
+	 * @testWith [""]
+	 *           ["2026-02-31"]
+	 *
+	 * @param string $value The submitted value.
+	 */
+	public function test_invalid_date_values_remain_unchanged( string $value ) {
+		$this->register_date_fields();
+
+		$document_object = new DocumentObject(
+			[
+				'checkout' => [
+					'additional_fields' => [ 'namespace/order_date' => $value ],
+				],
+			]
+		);
+		$document_object->set_customer( new WC_Customer( 0 ) );
+
+		$data = $document_object->get_data();
+
+		$this->assertArrayHasKey( 'namespace/order_date', $data['checkout']['additional_fields'] );
+		$this->assertSame( $value, $data['checkout']['additional_fields']['namespace/order_date'] );
+	}
+
+	/**
+	 * @testdox Values of other field types are passed through untouched.
+	 */
+	public function test_non_date_values_are_untouched() {
+		\woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => 'namespace/order_field',
+				'label'    => 'Order Field',
+				'location' => 'order',
+				'type'     => 'text',
+			)
+		);
+
+		$document_object = new DocumentObject(
+			[
+				'checkout' => [
+					'additional_fields' => [
+						'namespace/order_field' => '2026-01-15',
+						'namespace/unknown'     => '2026-01-15',
+					],
+				],
+			]
+		);
+		$document_object->set_customer( new WC_Customer( 0 ) );
+
+		$fields = $document_object->get_data()['checkout']['additional_fields'];
+
+		$this->assertSame( '2026-01-15', $fields['namespace/order_field'], 'A text field holding a date-like string should not be converted.' );
+		$this->assertSame( '2026-01-15', $fields['namespace/unknown'], 'An unregistered key should not be converted.' );
+	}
+
+	/**
+	 * @testdox Memoized data is rebuilt after the customer, cart, or context changes.
+	 */
+	public function test_memoized_data_is_invalidated() {
+		$document_object = new DocumentObject();
+		$customer        = new WC_Customer( 0 );
+		$customer->set_billing_first_name( 'Jane' );
+		$document_object->set_customer( $customer );
+
+		$this->assertSame( 'Jane', $document_object->get_data()['customer']['billing_address']['first_name'] );
+
+		$updated = new WC_Customer( 0 );
+		$updated->set_billing_first_name( 'John' );
+		$document_object->set_customer( $updated );
+
+		$this->assertSame( 'John', $document_object->get_data()['customer']['billing_address']['first_name'], 'get_data() should not serve a stale cache after the customer changes.' );
+
+		$document_object->set_context( 'billing_address' );
+
+		$this->assertArrayHasKey( 'address', $document_object->get_data()['customer'], 'get_data() should not serve a stale cache after the context changes.' );
+	}
+
 	/**
 	 * Get the schema.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/ValidationTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/ValidationTest.php
new file mode 100644
index 00000000000..2f22fa9a100
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsSchema/ValidationTest.php
@@ -0,0 +1,185 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Domain\Services\CheckoutFieldsSchema;
+
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\Validation;
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
+use WP_Error;
+use WP_UnitTestCase;
+
+/**
+ * Tests for the Validation class.
+ */
+class ValidationTest extends WP_UnitTestCase {
+
+	/**
+	 * @testdox Invalid schemas report the failing keyword and a reason without internal wrappers or placeholders.
+	 *
+	 * @testWith [{"formatMinimum": "2026-05-01"}, "/", "format"]
+	 *           [{"format": "time", "formatMinimum": "2026-05-01"}, "/format", "The value must be \"date\""]
+	 *           [{"format": "date", "formatMaximum": 20260501}, "/formatMaximum", "string"]
+	 *           [{"format": "date", "formatMaximum": "2026-02-30"}, "/formatMaximum", "date"]
+	 *           [{"format": "date", "formatMinimum": {"$data": "not-a-pointer"}}, "/formatMinimum/$data", "json-pointer"]
+	 *           [{"anyOf": [{"maxLength": -1}]}, "/anyOf/0/maxLength", "0"]
+	 *
+	 * @param array  $rules  The invalid schema.
+	 * @param string $path   The expected keyword path.
+	 * @param string $reason The expected explanation.
+	 */
+	public function test_schema_error_explains_invalid_keyword( array $rules, string $path, string $reason ): void {
+		$error = Validation::is_valid_schema( $rules );
+
+		$this->assertWPError( $error );
+		$message = $error->get_error_message();
+		$this->assertStringContainsString( 'At "' . $path . '"', $message );
+		$this->assertStringContainsString( $reason, $message );
+		$this->assertStringNotContainsString( '/properties/test', $message );
+		$this->assertStringNotContainsString( '{properties}', $message );
+	}
+
+	/**
+	 * @testdox A $data reference is accepted wherever the keyword's own value would be.
+	 *
+	 * One keyword per shape draft-07 gives these keywords: a number, a string, and an array.
+	 *
+	 * @testWith ["exclusiveMinimum"]
+	 *           ["pattern"]
+	 *           ["required"]
+	 *
+	 * @param string $keyword The keyword to set a $data reference on.
+	 */
+	public function test_data_references_are_accepted( string $keyword ) {
+		$rules = array( $keyword => array( '$data' => '1/plugin~1other-field' ) );
+
+		$this->assertTrue( Validation::is_valid_schema( $rules ), sprintf( 'The "%s" keyword should accept a $data reference.', $keyword ) );
+	}
+
+	/**
+	 * @testdox A keyword that cannot take a $data reference still rejects one.
+	 */
+	public function test_data_reference_on_unsupported_keyword_is_rejected() {
+		$this->assertInstanceOf( WP_Error::class, Validation::is_valid_schema( array( 'type' => array( '$data' => '1/plugin~1other-field' ) ) ) );
+	}
+
+	/**
+	 * @testdox A malformed $data reference is rejected.
+	 *
+	 * @testWith [{"$data": 1}]
+	 *           [{"$data": "1/plugin~1other-field", "extra": true}]
+	 *           [{"$data": "not-a-pointer"}]
+	 *           [{"$data": "/bad~2escape"}]
+	 *
+	 * @param array $reference The malformed reference.
+	 */
+	public function test_malformed_data_references_are_rejected( array $reference ) {
+		$this->assertInstanceOf( WP_Error::class, Validation::is_valid_schema( array( 'exclusiveMinimum' => $reference ) ) );
+	}
+
+	/**
+	 * @testdox Literal keyword values are still validated against their own types.
+	 *
+	 * @testWith [{"exclusiveMinimum": 20260101}, true]
+	 *           [{"exclusiveMinimum": "20260101"}, false]
+	 *           [{"type": "nonsense"}, false]
+	 *           [{"required": "a"}, false]
+	 *
+	 * @param array $rules      The rules to validate.
+	 * @param bool  $is_allowed Whether the rules should be accepted.
+	 */
+	public function test_literal_values_keep_their_constraints( array $rules, bool $is_allowed ) {
+		$this->assertSame( $is_allowed, ! is_wp_error( Validation::is_valid_schema( $rules ) ), 'Widening a keyword for $data should not let a wrongly typed literal through.' );
+	}
+	/**
+	 * @testdox Date limits compare strings with literal and cross-field bounds.
+	 *
+	 * @testWith ["formatMinimum", "2026-05-01", false]
+	 *           ["formatMinimum", "2026-05-02", true]
+	 *           ["formatMinimum", "2026-05-03", true]
+	 *           ["formatMaximum", "2026-05-01", true]
+	 *           ["formatMaximum", "2026-05-02", true]
+	 *           ["formatMaximum", "2026-05-03", false]
+	 *           ["formatExclusiveMinimum", "2026-05-01", false]
+	 *           ["formatExclusiveMinimum", "2026-05-02", false]
+	 *           ["formatExclusiveMinimum", "2026-05-03", true]
+	 *           ["formatExclusiveMaximum", "2026-05-01", true]
+	 *           ["formatExclusiveMaximum", "2026-05-02", false]
+	 *           ["formatExclusiveMaximum", "2026-05-03", false]
+	 *           ["formatMaximum", "2026-02-30", false]
+	 *           ["formatMaximum", "2026--02--01", false]
+	 *           ["formatMaximum", "2026/02/01", false]
+	 *
+	 * @param string $keyword  The comparison keyword.
+	 * @param string $value    The date being validated.
+	 * @param bool   $expected Whether the date should pass.
+	 */
+	public function test_date_format_limits( string $keyword, string $value, bool $expected ): void {
+		$sut = $this->createMock( DocumentObject::class );
+		$sut->method( 'get_data' )->willReturn(
+			array(
+				'date'            => $value,
+				'hotel/reference' => '2026-05-02',
+			)
+		);
+
+		foreach ( array( '2026-05-02', array( '$data' => '1/hotel~1reference' ), array( '$data' => '/hotel~1reference' ) ) as $limit ) {
+			$rules = array(
+				'properties' => array(
+					'date' => array(
+						'type'   => 'string',
+						'format' => 'date',
+						$keyword => $limit,
+					),
+				),
+			);
+
+			$this->assertTrue( Validation::is_valid_schema( $rules ) );
+			$this->assertSame( $expected, ! is_wp_error( Validation::validate_document_object( $sut, $rules ) ) );
+		}
+	}
+
+	/**
+	 * @testdox Missing and blank date references skip comparison, while wrong types fail.
+	 *
+	 * @testWith [[], true]
+	 *           [{"reference": ""}, true]
+	 *           [{"reference": null}, false]
+	 *           [{"reference": 20260501}, false]
+	 *           [{"reference": false}, false]
+	 *
+	 * @param array $values   The referenced field values.
+	 * @param bool  $expected Whether the comparison should pass.
+	 */
+	public function test_empty_date_references( array $values, bool $expected ): void {
+		$sut = $this->createMock( DocumentObject::class );
+		$sut->method( 'get_data' )->willReturn( array_merge( $values, array( 'date' => '2026-05-02' ) ) );
+		$rules = array(
+			'properties' => array(
+				'date' => array(
+					'format'        => 'date',
+					'formatMinimum' => array( '$data' => '1/reference' ),
+				),
+			),
+		);
+
+		$this->assertSame( $expected, ! is_wp_error( Validation::validate_document_object( $sut, $rules ) ) );
+	}
+
+	/**
+	 * @testdox Date limit rules require a date format and a date string or valid pointer.
+	 *
+	 * @testWith [{"formatMinimum": "2026-05-01"}, false]
+	 *           [{"format": "time", "formatMinimum": "12:00:00Z"}, false]
+	 *           [{"format": "date", "formatMaximum": 20260501}, false]
+	 *           [{"format": "date", "formatMaximum": "2026-02-30"}, false]
+	 *           [{"format": "date", "formatMinimum": {"$data": "not-a-pointer"}}, false]
+	 *           [{"format": "date", "formatMinimum": "2026-05-01"}, true]
+	 *           [{"format": "date", "formatMaximum": {"$data": "1/reference"}}, true]
+	 *
+	 * @param array $rules    The field rules.
+	 * @param bool  $expected Whether registration should accept them.
+	 */
+	public function test_date_limit_schema_registration( array $rules, bool $expected ): void {
+		$this->assertSame( $expected, ! is_wp_error( Validation::is_valid_schema( $rules ) ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
index 4c8e90bb850..a09d2810109 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
@@ -212,6 +212,7 @@ class CheckoutFieldsTest extends WP_UnitTestCase {

 		$this->assertArrayHasKey( 'plugin-namespace/delivery-date', $fields, 'Date fields should be a supported field type.' );
 		$this->assertSame( 'date', $fields['plugin-namespace/delivery-date']['type'] );
+		$this->assertSame( array(), $fields['plugin-namespace/delivery-date']['validation'], 'A date field without custom rules should not gain a validation schema.' );

 		// The constraint rules themselves are covered by DateFieldTypeTest; this only checks registration carries them through unresolved.
 		$this->assertSame( 'P0D', $fields['plugin-namespace/appointment-date']['min'] );
@@ -311,6 +312,191 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
 		$this->assertArrayNotHasKey( 'plugin-namespace/invalid-constraint', $this->controller->get_additional_fields() );
 	}

+	/**
+	 * @testdox A field whose rules reference another field with $data is registered.
+	 */
+	public function test_field_with_data_reference_rule_is_registered() {
+		$this->register_stay_dates();
+
+		$this->assertArrayHasKey( 'plugin-namespace/check-out', $this->controller->get_additional_fields(), 'A $data reference in a validation rule should not be rejected as an invalid schema.' );
+	}
+
+	/**
+	 * @testdox Date comparison schemas default to date strings and keep supplied keywords.
+	 *
+	 * @testWith [[], "string"]
+	 *           [{"type": "string"}, "string"]
+	 *           [{"format": "date"}, "string"]
+	 *           [{"type": ["string", "null"], "format": "date"}, ["string", "null"]]
+	 *
+	 * @param array        $schema The supplied schema keywords.
+	 * @param string|array $type   The expected schema type.
+	 */
+	public function test_date_comparison_schema_defaults( array $schema, $type ): void {
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'         => 'plugin-namespace/check-out',
+				'label'      => 'Check-out',
+				'location'   => 'order',
+				'type'       => 'date',
+				'validation' => array_merge( array( 'formatMinimum' => '2026-05-02' ), $schema ),
+			)
+		);
+
+		$fields = $this->controller->get_additional_fields();
+		$this->assertArrayHasKey( 'plugin-namespace/check-out', $fields, 'Date limits should register without explicit type and format keywords.' );
+		$field = $fields['plugin-namespace/check-out'];
+
+		$this->assertSame( $type, $field['validation']['type'] );
+		$this->assertSame( 'date', $field['validation']['format'] );
+		$this->assertTrue( $this->controller->is_valid_field( $field, $this->stay_document_object( '', '2026-05-02' ) ) );
+		$this->assertWPError( $this->controller->is_valid_field( $field, $this->stay_document_object( '', '2026-05-01' ) ) );
+	}
+
+	/**
+	 * @testdox Date schema defaults do not replace invalid keywords supplied by the caller.
+	 *
+	 * @testWith [{"format": "date-time"}]
+	 *           [{"format": null}]
+	 *           [{"type": null}]
+	 *
+	 * @param array $schema The invalid schema keywords.
+	 */
+	public function test_date_schema_defaults_preserve_invalid_keywords( array $schema ): void {
+		$this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'         => 'plugin-namespace/check-out',
+				'label'      => 'Check-out',
+				'location'   => 'order',
+				'type'       => 'date',
+				'validation' => array_merge( array( 'formatMaximum' => '2026-05-02' ), $schema ),
+			)
+		);
+
+		$this->assertArrayNotHasKey( 'plugin-namespace/check-out', $this->controller->get_additional_fields() );
+	}
+
+	/**
+	 * @testdox A $data rule orders one date field against another.
+	 *
+	 * @testWith ["2026-05-01", "2026-05-02", true]
+	 *           ["2026-05-01", "2026-05-01", false]
+	 *           ["2026-05-04", "2026-05-01", false]
+	 *
+	 * @param string $check_in  The value of the referenced field.
+	 * @param string $check_out The value of the field carrying the rule.
+	 * @param bool   $is_valid  Whether the pair should pass validation.
+	 */
+	public function test_data_reference_rule_compares_two_date_fields( string $check_in, string $check_out, bool $is_valid ) {
+		$this->register_stay_dates();
+
+		$field = $this->controller->get_additional_fields()['plugin-namespace/check-out'];
+
+		$this->assertSame(
+			$is_valid,
+			true === $this->controller->is_valid_field( $field, $this->stay_document_object( $check_in, $check_out ) ),
+			sprintf( 'Check-out %s against check-in %s was not judged as expected.', $check_out, $check_in )
+		);
+	}
+
+	/**
+	 * @testdox Optional blank dates do not trigger a cross-field comparison.
+	 *
+	 * @testWith ["2026-05-01", "", true]
+	 *           ["", "2026-05-04", true]
+	 *
+	 * @param string $check_in  The value of the referenced field.
+	 * @param string $check_out The value of the field carrying the rule.
+	 * @param bool   $is_valid  Whether the pair should pass validation.
+	 */
+	public function test_blank_dates_skip_cross_field_comparisons( string $check_in, string $check_out, bool $is_valid ) {
+		$this->register_stay_dates( true );
+
+		$field = $this->controller->get_additional_fields()['plugin-namespace/check-out'];
+
+		$this->assertSame( $is_valid, true === $this->controller->is_valid_field( $field, $this->stay_document_object( $check_in, $check_out ) ) );
+	}
+
+	/**
+	 * @testdox Each field type contributes its own keywords to the REST API value schema.
+	 */
+	public function test_prepare_field_value_schema() {
+		$fields = $this->controller->get_additional_fields();
+
+		$date = $this->controller->prepare_field_value_schema( array( 'type' => 'string' ), $fields['plugin-namespace/delivery-date'] );
+		$this->assertSame( 'string', $date['type'] );
+		$this->assertArrayNotHasKey( 'pattern', $date, 'Date validation runs outside the REST API value schema.' );
+
+		$checkbox = $this->controller->prepare_field_value_schema( array( 'type' => 'string' ), $fields['plugin-namespace/leave-on-porch'] );
+		$this->assertSame( 'boolean', $checkbox['type'] );
+
+		$select = $this->controller->prepare_field_value_schema( array( 'type' => 'string' ), $fields['plugin-namespace/job-function'] );
+		$this->assertSame( array( 'director', 'engineering', 'customer-support', 'other' ), $select['enum'] );
+
+		$text = $this->controller->prepare_field_value_schema( array( 'type' => 'string' ), $fields['plugin-namespace/gov-id'] );
+		$this->assertSame( array( 'type' => 'string' ), $text, 'A type with no keywords of its own should leave the schema alone.' );
+	}
+
+	/**
+	 * Registers a pair of date fields where the second must fall after the first.
+	 *
+	 * @param bool $allow_empty Whether the schema accepts an empty check-out.
+	 */
+	private function register_stay_dates( bool $allow_empty = false ) {
+		$validation = array(
+			'type'                   => 'string',
+			'format'                 => 'date',
+			'formatExclusiveMinimum' => array( '$data' => '1/plugin-namespace~1check-in' ),
+		);
+		if ( $allow_empty ) {
+			$validation = array( 'anyOf' => array( array( 'const' => '' ), $validation ) );
+		} else {
+			unset( $validation['type'], $validation['format'] );
+		}
+
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => 'plugin-namespace/check-in',
+				'label'    => 'Check-in',
+				'location' => 'order',
+				'type'     => 'date',
+			)
+		);
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'         => 'plugin-namespace/check-out',
+				'label'      => 'Check-out',
+				'location'   => 'order',
+				'type'       => 'date',
+				'validation' => $validation,
+			)
+		);
+	}
+
+	/**
+	 * Builds a document object holding the two stay dates as they reach rule evaluation.
+	 *
+	 * @param string $check_in  The value of the referenced field.
+	 * @param string $check_out The value of the field carrying the rule.
+	 * @return DocumentObject
+	 */
+	private function stay_document_object( string $check_in, string $check_out ): DocumentObject {
+		$document_object = new DocumentObject(
+			array(
+				'checkout' => array(
+					'additional_fields' => array(
+						'plugin-namespace/check-in'  => $check_in,
+						'plugin-namespace/check-out' => $check_out,
+					),
+				),
+			)
+		);
+		$document_object->set_customer( new \WC_Customer( 0 ) );
+
+		return $document_object;
+	}
+
 	/**
 	 * Registering a field before after_setup_theme warns the developer.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
index 55994100052..e767ec3f370 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php
@@ -2789,11 +2789,11 @@ class AdditionalFields extends \WP_Test_REST_TestCase {
 	}

 	/**
-	 * Test for errors when providing the wrong validation rules schema.
+	 * @testdox Invalid validation schemas report the keyword and its allowed values.
 	 */
 	public function test_invalid_validation_rules_schema() {
 		$this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );
-		$doing_it_wrong_mocker = $this->add_doing_it_wrong_error_mocker( 'woocommerce_register_additional_checkout_field', 'Unable to register field with id: "namespace/test-id". validation: The properties must match schema: {properties}' );
+		$doing_it_wrong_mocker = $this->add_doing_it_wrong_error_mocker( 'woocommerce_register_additional_checkout_field', 'Unable to register field with id: "namespace/test-id". validation: At "/type": The value must be one of: "array", "boolean", "integer", "null", "number", "object", "string"; The data (string) must match the type: array.' );
 		\woocommerce_register_additional_checkout_field(
 			array(
 				'id'         => 'namespace/test-id',