diff --git a/wp-admin/includes/schema.php b/wp-admin/includes/schema.php
index 795060f28b..1f772c929f 100644
--- a/wp-admin/includes/schema.php
+++ b/wp-admin/includes/schema.php
@@ -563,6 +563,9 @@ function populate_options( array $options = array() ) {
// 6.9.0
'wp_notes_notify' => 1,
+
+ // 7.0.0
+ 'enable_real_time_collaboration' => 1,
);
// 3.3.0
diff --git a/wp-admin/options-writing.php b/wp-admin/options-writing.php
index 6f85b54679..1943f42176 100644
--- a/wp-admin/options-writing.php
+++ b/wp-admin/options-writing.php
@@ -109,6 +109,13 @@ unset( $post_formats['standard'] );
+
+ |
+
+ />
+
+ |
+
diff --git a/wp-admin/options.php b/wp-admin/options.php
index 8db5cf50f2..57c22be86d 100644
--- a/wp-admin/options.php
+++ b/wp-admin/options.php
@@ -153,6 +153,7 @@ $allowed_options = array(
'default_email_category',
'default_link_category',
'default_post_format',
+ 'enable_real_time_collaboration',
),
);
$allowed_options['misc'] = array();
diff --git a/wp-includes/certificates/ca-bundle.crt b/wp-includes/certificates/ca-bundle.crt
index 65be891eea..5d325ac6fa 100644
--- a/wp-includes/certificates/ca-bundle.crt
+++ b/wp-includes/certificates/ca-bundle.crt
@@ -1,7 +1,7 @@
##
## Bundle of CA Root Certificates
##
-## Certificate data from Mozilla as of: Tue Dec 2 04:12:02 2025 GMT
+## Certificate data from Mozilla as of: Tue Nov 4 04:12:02 2025 GMT
##
## Find updated versions here: https://curl.se/docs/caextract.html
##
@@ -15,8 +15,8 @@
## an Apache+mod_ssl webserver for SSL client authentication.
## Just configure this file as the SSLCACertificateFile.
##
-## Conversion done with mk-ca-bundle.pl version 1.30.
-## SHA256: a903b3cd05231e39332515ef7ebe37e697262f39515a52015c23c62805b73cd0
+## Conversion done with mk-ca-bundle.pl version 1.29.
+## SHA256: 039132bff5179ce57cec5803ba59fe37abe6d0297aeb538c5af27847f0702517
##
@@ -3167,6 +3167,96 @@ bbd+NvBNEU/zy4k6LHiRUKNbwMp1JvK/kF0LgoxgKJ/GcJpo5PECMFxYDlZ2z1jD1xCMuo6u47xk
dUfFVZDj/bpV6wfEU6s3qe4hsiFbYI89MvHVI5TWWA==
-----END CERTIFICATE-----
+CommScope Public Trust ECC Root-01
+==================================
+-----BEGIN CERTIFICATE-----
+MIICHTCCAaOgAwIBAgIUQ3CCd89NXTTxyq4yLzf39H91oJ4wCgYIKoZIzj0EAwMwTjELMAkGA1UE
+BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz
+dCBFQ0MgUm9vdC0wMTAeFw0yMTA0MjgxNzM1NDNaFw00NjA0MjgxNzM1NDJaME4xCzAJBgNVBAYT
+AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg
+RUNDIFJvb3QtMDEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARLNumuV16ocNfQj3Rid8NeeqrltqLx
+eP0CflfdkXmcbLlSiFS8LwS+uM32ENEp7LXQoMPwiXAZu1FlxUOcw5tjnSCDPgYLpkJEhRGnSjot
+6dZoL0hOUysHP029uax3OVejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G
+A1UdDgQWBBSOB2LAUN3GGQYARnQE9/OufXVNMDAKBggqhkjOPQQDAwNoADBlAjEAnDPfQeMjqEI2
+Jpc1XHvr20v4qotzVRVcrHgpD7oh2MSg2NED3W3ROT3Ek2DS43KyAjB8xX6I01D1HiXo+k515liW
+pDVfG2XqYZpwI7UNo5uSUm9poIyNStDuiw7LR47QjRE=
+-----END CERTIFICATE-----
+
+CommScope Public Trust ECC Root-02
+==================================
+-----BEGIN CERTIFICATE-----
+MIICHDCCAaOgAwIBAgIUKP2ZYEFHpgE6yhR7H+/5aAiDXX0wCgYIKoZIzj0EAwMwTjELMAkGA1UE
+BhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBUcnVz
+dCBFQ0MgUm9vdC0wMjAeFw0yMTA0MjgxNzQ0NTRaFw00NjA0MjgxNzQ0NTNaME4xCzAJBgNVBAYT
+AlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1c3Qg
+RUNDIFJvb3QtMDIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAR4MIHoYx7l63FRD/cHB8o5mXxO1Q/M
+MDALj2aTPs+9xYa9+bG3tD60B8jzljHz7aRP+KNOjSkVWLjVb3/ubCK1sK9IRQq9qEmUv4RDsNuE
+SgMjGWdqb8FuvAY5N9GIIvejQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G
+A1UdDgQWBBTmGHX/72DehKT1RsfeSlXjMjZ59TAKBggqhkjOPQQDAwNnADBkAjAmc0l6tqvmSfR9
+Uj/UQQSugEODZXW5hYA4O9Zv5JOGq4/nich/m35rChJVYaoR4HkCMHfoMXGsPHED1oQmHhS48zs7
+3u1Z/GtMMH9ZzkXpc2AVmkzw5l4lIhVtwodZ0LKOag==
+-----END CERTIFICATE-----
+
+CommScope Public Trust RSA Root-01
+==================================
+-----BEGIN CERTIFICATE-----
+MIIFbDCCA1SgAwIBAgIUPgNJgXUWdDGOTKvVxZAplsU5EN0wDQYJKoZIhvcNAQELBQAwTjELMAkG
+A1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBU
+cnVzdCBSU0EgUm9vdC0wMTAeFw0yMTA0MjgxNjQ1NTRaFw00NjA0MjgxNjQ1NTNaME4xCzAJBgNV
+BAYTAlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1
+c3QgUlNBIFJvb3QtMDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwSGWjDR1C45Ft
+nYSkYZYSwu3D2iM0GXb26v1VWvZVAVMP8syMl0+5UMuzAURWlv2bKOx7dAvnQmtVzslhsuitQDy6
+uUEKBU8bJoWPQ7VAtYXR1HHcg0Hz9kXHgKKEUJdGzqAMxGBWBB0HW0alDrJLpA6lfO741GIDuZNq
+ihS4cPgugkY4Iw50x2tBt9Apo52AsH53k2NC+zSDO3OjWiE260f6GBfZumbCk6SP/F2krfxQapWs
+vCQz0b2If4b19bJzKo98rwjyGpg/qYFlP8GMicWWMJoKz/TUyDTtnS+8jTiGU+6Xn6myY5QXjQ/c
+Zip8UlF1y5mO6D1cv547KI2DAg+pn3LiLCuz3GaXAEDQpFSOm117RTYm1nJD68/A6g3czhLmfTif
+BSeolz7pUcZsBSjBAg/pGG3svZwG1KdJ9FQFa2ww8esD1eo9anbCyxooSU1/ZOD6K9pzg4H/kQO9
+lLvkuI6cMmPNn7togbGEW682v3fuHX/3SZtS7NJ3Wn2RnU3COS3kuoL4b/JOHg9O5j9ZpSPcPYeo
+KFgo0fEbNttPxP/hjFtyjMcmAyejOQoBqsCyMWCDIqFPEgkBEa801M/XrmLTBQe0MXXgDW1XT2mH
++VepuhX2yFJtocucH+X8eKg1mp9BFM6ltM6UCBwJrVbl2rZJmkrqYxhTnCwuwwIDAQABo0IwQDAP
+BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUN12mmnQywsL5x6YVEFm4
+5P3luG0wDQYJKoZIhvcNAQELBQADggIBAK+nz97/4L1CjU3lIpbfaOp9TSp90K09FlxD533Ahuh6
+NWPxzIHIxgvoLlI1pKZJkGNRrDSsBTtXAOnTYtPZKdVUvhwQkZyybf5Z/Xn36lbQnmhUQo8mUuJM
+3y+Xpi/SB5io82BdS5pYV4jvguX6r2yBS5KPQJqTRlnLX3gWsWc+QgvfKNmwrZggvkN80V4aCRck
+jXtdlemrwWCrWxhkgPut4AZ9HcpZuPN4KWfGVh2vtrV0KnahP/t1MJ+UXjulYPPLXAziDslg+Mkf
+Foom3ecnf+slpoq9uC02EJqxWE2aaE9gVOX2RhOOiKy8IUISrcZKiX2bwdgt6ZYD9KJ0DLwAHb/W
+NyVntHKLr4W96ioDj8z7PEQkguIBpQtZtjSNMgsSDesnwv1B10A8ckYpwIzqug/xBpMu95yo9GA+
+o/E4Xo4TwbM6l4c/ksp4qRyv0LAbJh6+cOx69TOY6lz/KwsETkPdY34Op054A5U+1C0wlREQKC6/
+oAI+/15Z0wUOlV9TRe9rh9VIzRamloPh37MG88EU26fsHItdkJANclHnYfkUyq+Dj7+vsQpZXdxc
+1+SWrVtgHdqul7I52Qb1dgAT+GhMIbA1xNxVssnBQVocicCMb3SgazNNtQEo/a2tiRc7ppqEvOuM
+6sRxJKi6KfkIsidWNTJf6jn7MZrVGczw
+-----END CERTIFICATE-----
+
+CommScope Public Trust RSA Root-02
+==================================
+-----BEGIN CERTIFICATE-----
+MIIFbDCCA1SgAwIBAgIUVBa/O345lXGN0aoApYYNK496BU4wDQYJKoZIhvcNAQELBQAwTjELMAkG
+A1UEBhMCVVMxEjAQBgNVBAoMCUNvbW1TY29wZTErMCkGA1UEAwwiQ29tbVNjb3BlIFB1YmxpYyBU
+cnVzdCBSU0EgUm9vdC0wMjAeFw0yMTA0MjgxNzE2NDNaFw00NjA0MjgxNzE2NDJaME4xCzAJBgNV
+BAYTAlVTMRIwEAYDVQQKDAlDb21tU2NvcGUxKzApBgNVBAMMIkNvbW1TY29wZSBQdWJsaWMgVHJ1
+c3QgUlNBIFJvb3QtMDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDh+g77aAASyE3V
+rCLENQE7xVTlWXZjpX/rwcRqmL0yjReA61260WI9JSMZNRTpf4mnG2I81lDnNJUDMrG0kyI9p+Kx
+7eZ7Ti6Hmw0zdQreqjXnfuU2mKKuJZ6VszKWpCtYHu8//mI0SFHRtI1CrWDaSWqVcN3SAOLMV2MC
+e5bdSZdbkk6V0/nLKR8YSvgBKtJjCW4k6YnS5cciTNxzhkcAqg2Ijq6FfUrpuzNPDlJwnZXjfG2W
+Wy09X6GDRl224yW4fKcZgBzqZUPckXk2LHR88mcGyYnJ27/aaL8j7dxrrSiDeS/sOKUNNwFnJ5rp
+M9kzXzehxfCrPfp4sOcsn/Y+n2Dg70jpkEUeBVF4GiwSLFworA2iI540jwXmojPOEXcT1A6kHkIf
+hs1w/tkuFT0du7jyU1fbzMZ0KZwYszZ1OC4PVKH4kh+Jlk+71O6d6Ts2QrUKOyrUZHk2EOH5kQMr
+eyBUzQ0ZGshBMjTRsJnhkB4BQDa1t/qp5Xd1pCKBXbCL5CcSD1SIxtuFdOa3wNemKfrb3vOTlycE
+VS8KbzfFPROvCgCpLIscgSjX74Yxqa7ybrjKaixUR9gqiC6vwQcQeKwRoi9C8DfF8rhW3Q5iLc4t
+Vn5V8qdE9isy9COoR+jUKgF4z2rDN6ieZdIs5fq6M8EGRPbmz6UNp2YINIos8wIDAQABo0IwQDAP
+BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUR9DnsSL/nSz12Vdgs7Gx
+cJXvYXowDQYJKoZIhvcNAQELBQADggIBAIZpsU0v6Z9PIpNojuQhmaPORVMbc0RTAIFhzTHjCLqB
+KCh6krm2qMhDnscTJk3C2OVVnJJdUNjCK9v+5qiXz1I6JMNlZFxHMaNlNRPDk7n3+VGXu6TwYofF
+1gbTl4MgqX67tiHCpQ2EAOHyJxCDut0DgdXdaMNmEMjRdrSzbymeAPnCKfWxkxlSaRosTKCL4BWa
+MS/TiJVZbuXEs1DIFAhKm4sTg7GkcrI7djNB3NyqpgdvHSQSn8h2vS/ZjvQs7rfSOBAkNlEv41xd
+gSGn2rtO/+YHqP65DSdsu3BaVXoT6fEqSWnHX4dXTEN5bTpl6TBcQe7rd6VzEojov32u5cSoHw2O
+HG1QAk8mGEPej1WFsQs3BWDJVTkSBKEqz3EWnzZRSb9wO55nnPt7eck5HHisd5FUmrh1CoFSl+Nm
+YWvtPjgelmFV4ZFUjO2MJB+ByRCac5krFk5yAD9UG/iNuovnFNa2RU9g7Jauwy8CTl2dlklyALKr
+dVwPaFsdZcJfMw8eD/A7hvWwTruc9+olBdytoptLFwG+Qt81IR2tq670v64fG9PiO/yzcnMcmyiQ
+iRM9HcEARwmWmjgb3bHPDcK0RPOWlc4yOo80nOAXx17Org3bhzjlP1v9mxnhMUF6cKojawHhRUzN
+lM47ni3niAIi9G7oyOzWPPO5std3eqx7
+-----END CERTIFICATE-----
+
Telekom Security TLS ECC Root 2020
==================================
-----BEGIN CERTIFICATE-----
diff --git a/wp-includes/collaboration.php b/wp-includes/collaboration.php
new file mode 100644
index 0000000000..1da7f2c367
--- /dev/null
+++ b/wp-includes/collaboration.php
@@ -0,0 +1,24 @@
+storage = $storage;
+ }
+
+ /**
+ * Registers REST API routes.
+ *
+ * @since 7.0.0
+ */
+ public function register_routes(): void {
+ $typed_update_args = array(
+ 'properties' => array(
+ 'data' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'type' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'enum' => array(
+ self::UPDATE_TYPE_COMPACTION,
+ self::UPDATE_TYPE_SYNC_STEP1,
+ self::UPDATE_TYPE_SYNC_STEP2,
+ self::UPDATE_TYPE_UPDATE,
+ ),
+ ),
+ ),
+ 'required' => true,
+ 'type' => 'object',
+ );
+
+ $room_args = array(
+ 'after' => array(
+ 'minimum' => 0,
+ 'required' => true,
+ 'type' => 'integer',
+ ),
+ 'awareness' => array(
+ 'required' => true,
+ 'type' => 'object',
+ ),
+ 'client_id' => array(
+ 'minimum' => 1,
+ 'required' => true,
+ 'type' => 'integer',
+ ),
+ 'room' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'pattern' => '^[^/]+/[^/:]+(?::\\S+)?$',
+ ),
+ 'updates' => array(
+ 'items' => $typed_update_args,
+ 'minItems' => 0,
+ 'required' => true,
+ 'type' => 'array',
+ ),
+ );
+
+ register_rest_route(
+ self::REST_NAMESPACE,
+ '/updates',
+ array(
+ 'methods' => array( WP_REST_Server::CREATABLE ),
+ 'callback' => array( $this, 'handle_request' ),
+ 'permission_callback' => array( $this, 'check_permissions' ),
+ 'args' => array(
+ 'rooms' => array(
+ 'items' => array(
+ 'properties' => $room_args,
+ 'type' => 'object',
+ ),
+ 'required' => true,
+ 'type' => 'array',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Checks if the current user has permission to access a room.
+ *
+ * @since 7.0.0
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @return bool|WP_Error True if user has permission, otherwise WP_Error with details.
+ */
+ public function check_permissions( WP_REST_Request $request ) {
+ // Minimum cap check. Is user logged in with a contributor role or higher?
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ __( 'You do not have permission to perform this action' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ $rooms = $request['rooms'];
+
+ foreach ( $rooms as $room ) {
+ $room = $room['room'];
+ $type_parts = explode( '/', $room, 2 );
+ $object_parts = explode( ':', $type_parts[1] ?? '', 2 );
+
+ $entity_kind = $type_parts[0];
+ $entity_name = $object_parts[0];
+ $object_id = $object_parts[1] ?? null;
+
+ if ( ! $this->can_user_sync_entity_type( $entity_kind, $entity_name, $object_id ) ) {
+ return new WP_Error(
+ 'rest_cannot_edit',
+ sprintf(
+ /* translators: %s: The room name encodes the current entity being synced. */
+ __( 'You do not have permission to sync this entity: %s.' ),
+ $room
+ ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Handles request: stores sync updates and awareness data, and returns
+ * updates the client is missing.
+ *
+ * @since 7.0.0
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @return WP_REST_Response|WP_Error Response object or error.
+ */
+ public function handle_request( WP_REST_Request $request ) {
+ $rooms = $request['rooms'];
+ $response = array(
+ 'rooms' => array(),
+ );
+
+ foreach ( $rooms as $room_request ) {
+ $awareness = $room_request['awareness'];
+ $client_id = $room_request['client_id'];
+ $cursor = $room_request['after'];
+ $room = $room_request['room'];
+
+ // Merge awareness state.
+ $merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );
+
+ // The lowest client ID is nominated to perform compaction when needed.
+ $is_compactor = false;
+ if ( count( $merged_awareness ) > 0 ) {
+ $is_compactor = min( array_keys( $merged_awareness ) ) === $client_id;
+ }
+
+ // Process each update according to its type.
+ foreach ( $room_request['updates'] as $update ) {
+ $result = $this->process_sync_update( $room, $client_id, $cursor, $update );
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+ }
+
+ // Get updates for this client.
+ $room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor );
+ $room_response['awareness'] = $merged_awareness;
+
+ $response['rooms'][] = $room_response;
+ }
+
+ return new WP_REST_Response( $response, 200 );
+ }
+
+ /**
+ * Checks if the current user can sync a specific entity type.
+ *
+ * @since 7.0.0
+ *
+ * @param string $entity_kind The entity kind, e.g. 'postType', 'taxonomy', 'root'.
+ * @param string $entity_name The entity name, e.g. 'post', 'category', 'site'.
+ * @param string|null $object_id The object ID / entity key for single entities, null for collections.
+ * @return bool True if user has permission, otherwise false.
+ */
+ private function can_user_sync_entity_type( string $entity_kind, string $entity_name, ?string $object_id ): bool {
+ // Handle single post type entities with a defined object ID.
+ if ( 'postType' === $entity_kind && is_numeric( $object_id ) ) {
+ return current_user_can( 'edit_post', (int) $object_id );
+ }
+
+ // Handle single taxonomy term entities with a defined object ID.
+ if ( 'taxonomy' === $entity_kind && is_numeric( $object_id ) ) {
+ $taxonomy = get_taxonomy( $entity_name );
+ return isset( $taxonomy->cap->assign_terms ) && current_user_can( $taxonomy->cap->assign_terms );
+ }
+
+ // All the remaining checks are for collections. If an object ID is provided,
+ // reject the request.
+ if ( null !== $object_id ) {
+ return false;
+ }
+
+ // For postType collections, check if the user can edit posts of this type.
+ if ( 'postType' === $entity_kind ) {
+ $post_type_object = get_post_type_object( $entity_name );
+ if ( ! isset( $post_type_object->cap->edit_posts ) ) {
+ return false;
+ }
+
+ return current_user_can( $post_type_object->cap->edit_posts );
+ }
+
+ // Collection syncing does not exchange entity data. It only signals if
+ // another user has updated an entity in the collection. Therefore, we only
+ // compare against an allow list of collection types.
+ $allowed_collection_entity_kinds = array(
+ 'postType',
+ 'root',
+ 'taxonomy',
+ );
+
+ return in_array( $entity_kind, $allowed_collection_entity_kinds, true );
+ }
+
+ /**
+ * Processes and stores an awareness update from a client.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $client_id Client identifier.
+ * @param array|null $awareness_update Awareness state sent by the client.
+ * @return array> Map of client ID to awareness state.
+ */
+ private function process_awareness_update( string $room, int $client_id, ?array $awareness_update ): array {
+ $existing_awareness = $this->storage->get_awareness_state( $room );
+ $updated_awareness = array();
+ $current_time = time();
+
+ foreach ( $existing_awareness as $entry ) {
+ // Remove this client's entry (it will be updated below).
+ if ( $client_id === $entry['client_id'] ) {
+ continue;
+ }
+
+ // Remove entries that have expired.
+ if ( $current_time - $entry['updated_at'] >= self::AWARENESS_TIMEOUT ) {
+ continue;
+ }
+
+ $updated_awareness[] = $entry;
+ }
+
+ // Add this client's awareness state.
+ if ( null !== $awareness_update ) {
+ $updated_awareness[] = array(
+ 'client_id' => $client_id,
+ 'state' => $awareness_update,
+ 'updated_at' => $current_time,
+ );
+ }
+
+ // This action can fail, but it shouldn't fail the entire request.
+ $this->storage->set_awareness_state( $room, $updated_awareness );
+
+ // Convert to client_id => state map for response.
+ $response = array();
+ foreach ( $updated_awareness as $entry ) {
+ $response[ $entry['client_id'] ] = $entry['state'];
+ }
+
+ return $response;
+ }
+
+ /**
+ * Processes a sync update based on its type.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $client_id Client identifier.
+ * @param int $cursor Client cursor (marker of last seen update).
+ * @param array{data: string, type: string} $update Sync update.
+ * @return true|WP_Error True on success, WP_Error on storage failure.
+ */
+ private function process_sync_update( string $room, int $client_id, int $cursor, array $update ) {
+ $data = $update['data'];
+ $type = $update['type'];
+
+ switch ( $type ) {
+ case self::UPDATE_TYPE_COMPACTION:
+ /*
+ * Compaction replaces updates the client has already seen. Only remove
+ * updates with markers before the client's cursor to preserve updates
+ * that arrived since the client's last sync.
+ *
+ * Check for a newer compaction update first. If one exists, skip this
+ * compaction to avoid overwriting it.
+ */
+ $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
+ $has_newer_compaction = false;
+
+ foreach ( $updates_after_cursor as $existing ) {
+ if ( self::UPDATE_TYPE_COMPACTION === $existing['type'] ) {
+ $has_newer_compaction = true;
+ break;
+ }
+ }
+
+ if ( ! $has_newer_compaction ) {
+ if ( ! $this->storage->remove_updates_before_cursor( $room, $cursor ) ) {
+ return new WP_Error(
+ 'rest_sync_storage_error',
+ __( 'Failed to remove updates during compaction.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return $this->add_update( $room, $client_id, $type, $data );
+ }
+ break;
+
+ case self::UPDATE_TYPE_SYNC_STEP1:
+ case self::UPDATE_TYPE_SYNC_STEP2:
+ case self::UPDATE_TYPE_UPDATE:
+ /*
+ * Sync step 1 announces a client's state vector. Other clients need
+ * to see it so they can respond with sync_step2 containing missing
+ * updates. The cursor-based filtering prevents re-delivery.
+ *
+ * Sync step 2 contains updates for a specific client.
+ *
+ * All updates are stored persistently.
+ */
+ return $this->add_update( $room, $client_id, $type, $data );
+ }
+
+ return new WP_Error(
+ 'rest_invalid_update_type',
+ __( 'Invalid sync update type.' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ /**
+ * Adds an update to a room's update list via storage.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $client_id Client identifier.
+ * @param string $type Update type (sync_step1, sync_step2, update, compaction).
+ * @param string $data Base64-encoded update data.
+ * @return true|WP_Error True on success, WP_Error on storage failure.
+ */
+ private function add_update( string $room, int $client_id, string $type, string $data ) {
+ $update = array(
+ 'client_id' => $client_id,
+ 'data' => $data,
+ 'type' => $type,
+ );
+
+ if ( ! $this->storage->add_update( $room, $update ) ) {
+ return new WP_Error(
+ 'rest_sync_storage_error',
+ __( 'Failed to store sync update.' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets sync updates for a specific client from a room after a given cursor.
+ *
+ * Delegates cursor-based retrieval to the storage layer, then applies
+ * client-specific filtering and compaction logic.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $client_id Client identifier.
+ * @param int $cursor Return updates after this cursor.
+ * @param bool $is_compactor True if this client is nominated to perform compaction.
+ * @return array{
+ * end_cursor: int,
+ * should_compact: bool,
+ * room: string,
+ * total_updates: int,
+ * updates: array,
+ * } Response data for this room.
+ */
+ private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array {
+ $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor );
+ $total_updates = $this->storage->get_update_count( $room );
+
+ // Filter out this client's updates, except compaction updates.
+ $typed_updates = array();
+ foreach ( $updates_after_cursor as $update ) {
+ if ( $client_id === $update['client_id'] && self::UPDATE_TYPE_COMPACTION !== $update['type'] ) {
+ continue;
+ }
+
+ $typed_updates[] = array(
+ 'data' => $update['data'],
+ 'type' => $update['type'],
+ );
+ }
+
+ $should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD;
+
+ return array(
+ 'end_cursor' => $this->storage->get_cursor( $room ),
+ 'room' => $room,
+ 'should_compact' => $should_compact,
+ 'total_updates' => $total_updates,
+ 'updates' => $typed_updates,
+ );
+ }
+}
diff --git a/wp-includes/collaboration/class-wp-sync-post-meta-storage.php b/wp-includes/collaboration/class-wp-sync-post-meta-storage.php
new file mode 100644
index 0000000000..d0f1c99736
--- /dev/null
+++ b/wp-includes/collaboration/class-wp-sync-post-meta-storage.php
@@ -0,0 +1,322 @@
+
+ */
+ private array $room_cursors = array();
+
+ /**
+ * Cache of update counts by room.
+ *
+ * @since 7.0.0
+ * @var array
+ */
+ private array $room_update_counts = array();
+
+ /**
+ * Cache of storage post IDs by room hash.
+ *
+ * @since 7.0.0
+ * @var array
+ */
+ private static array $storage_post_ids = array();
+
+ /**
+ * Adds a sync update to a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param mixed $update Sync update.
+ * @return bool True on success, false on failure.
+ */
+ public function add_update( string $room, $update ): bool {
+ $post_id = $this->get_storage_post_id( $room );
+ if ( null === $post_id ) {
+ return false;
+ }
+
+ // Create an envelope and stamp each update to enable cursor-based filtering.
+ $envelope = array(
+ 'timestamp' => $this->get_time_marker(),
+ 'value' => $update,
+ );
+
+ return (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false );
+ }
+
+ /**
+ * Retrieves all sync updates for a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @return array Sync updates.
+ */
+ private function get_all_updates( string $room ): array {
+ $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency.
+
+ $post_id = $this->get_storage_post_id( $room );
+ if ( null === $post_id ) {
+ return array();
+ }
+
+ $updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false );
+
+ if ( ! is_array( $updates ) ) {
+ $updates = array();
+ }
+
+ // Filter out any updates that don't have the expected structure.
+ $updates = array_filter(
+ $updates,
+ static function ( $update ): bool {
+ return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] );
+ }
+ );
+
+ $this->room_update_counts[ $room ] = count( $updates );
+
+ return $updates;
+ }
+
+ /**
+ * Gets awareness state for a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @return array Awareness state.
+ */
+ public function get_awareness_state( string $room ): array {
+ $post_id = $this->get_storage_post_id( $room );
+ if ( null === $post_id ) {
+ return array();
+ }
+
+ $awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true );
+
+ if ( ! is_array( $awareness ) ) {
+ return array();
+ }
+
+ return array_values( $awareness );
+ }
+
+ /**
+ * Sets awareness state for a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param array $awareness Serializable awareness state.
+ * @return bool True on success, false on failure.
+ */
+ public function set_awareness_state( string $room, array $awareness ): bool {
+ $post_id = $this->get_storage_post_id( $room );
+ if ( null === $post_id ) {
+ return false;
+ }
+
+ // update_post_meta returns false if the value is the same as the existing value.
+ update_post_meta( $post_id, self::AWARENESS_META_KEY, $awareness );
+ return true;
+ }
+
+ /**
+ * Gets the current cursor for a given room.
+ *
+ * The cursor is set during get_updates_after_cursor() and represents the
+ * point in time just before the updates were retrieved, with a small buffer
+ * to ensure consistency.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @return int Current cursor for the room.
+ */
+ public function get_cursor( string $room ): int {
+ return $this->room_cursors[ $room ] ?? 0;
+ }
+
+ /**
+ * Gets or creates the storage post for a given room.
+ *
+ * Each room gets its own dedicated post so that post meta cache
+ * invalidation is scoped to a single room rather than all of them.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @return int|null Post ID.
+ */
+ private function get_storage_post_id( string $room ): ?int {
+ $room_hash = md5( $room );
+
+ if ( isset( self::$storage_post_ids[ $room_hash ] ) ) {
+ return self::$storage_post_ids[ $room_hash ];
+ }
+
+ // Try to find an existing post for this room.
+ $posts = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => 1,
+ 'post_status' => 'publish',
+ 'name' => $room_hash,
+ 'fields' => 'ids',
+ )
+ );
+
+ $post_id = array_first( $posts );
+ if ( is_int( $post_id ) ) {
+ self::$storage_post_ids[ $room_hash ] = $post_id;
+ return $post_id;
+ }
+
+ // Create new post for this room.
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'post_status' => 'publish',
+ 'post_title' => 'Sync Storage',
+ 'post_name' => $room_hash,
+ )
+ );
+
+ if ( is_int( $post_id ) ) {
+ self::$storage_post_ids[ $room_hash ] = $post_id;
+ return $post_id;
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets the current time in milliseconds as a comparable time marker.
+ *
+ * @since 7.0.0
+ *
+ * @return int Current time in milliseconds.
+ */
+ private function get_time_marker(): int {
+ return (int) floor( microtime( true ) * 1000 );
+ }
+
+ /**
+ * Gets the number of updates stored for a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @return int Number of updates stored for the room.
+ */
+ public function get_update_count( string $room ): int {
+ return $this->room_update_counts[ $room ] ?? 0;
+ }
+
+ /**
+ * Retrieves sync updates from a room for a given client and cursor. Updates
+ * from the specified client should be excluded.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $cursor Return updates after this cursor.
+ * @return array Sync updates.
+ */
+ public function get_updates_after_cursor( string $room, int $cursor ): array {
+ $all_updates = $this->get_all_updates( $room );
+ $updates = array();
+
+ foreach ( $all_updates as $update ) {
+ if ( $update['timestamp'] > $cursor ) {
+ $updates[] = $update;
+ }
+ }
+
+ // Sort by timestamp to ensure order.
+ usort(
+ $updates,
+ fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp']
+ );
+
+ return wp_list_pluck( $updates, 'value' );
+ }
+
+ /**
+ * Removes updates from a room that are older than the given cursor.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $cursor Remove updates with markers < this cursor.
+ * @return bool True on success, false on failure.
+ */
+ public function remove_updates_before_cursor( string $room, int $cursor ): bool {
+ $post_id = $this->get_storage_post_id( $room );
+ if ( null === $post_id ) {
+ return false;
+ }
+
+ $all_updates = $this->get_all_updates( $room );
+
+ // Remove all updates for the room and re-store only those that are newer than the cursor.
+ if ( ! delete_post_meta( $post_id, self::SYNC_UPDATE_META_KEY ) ) {
+ return false;
+ }
+
+ // Re-store envelopes directly to avoid double-wrapping by add_update().
+ $add_result = true;
+ foreach ( $all_updates as $envelope ) {
+ if ( $add_result && $envelope['timestamp'] >= $cursor ) {
+ $add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false );
+ }
+ }
+
+ return $add_result;
+ }
+}
diff --git a/wp-includes/collaboration/interface-wp-sync-storage.php b/wp-includes/collaboration/interface-wp-sync-storage.php
new file mode 100644
index 0000000000..d84dbeb1e4
--- /dev/null
+++ b/wp-includes/collaboration/interface-wp-sync-storage.php
@@ -0,0 +1,86 @@
+ Awareness state.
+ */
+ public function get_awareness_state( string $room ): array;
+
+ /**
+ * Gets the current cursor for a given room. This should return a monotonically
+ * increasing integer that represents the last update that was returned for the
+ * room during the current request. This allows clients to retrieve updates
+ * after a specific cursor on subsequent requests.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @return int Current cursor for the room.
+ */
+ public function get_cursor( string $room ): int;
+
+ /**
+ * Gets the total number of stored updates for a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @return int Total number of updates.
+ */
+ public function get_update_count( string $room ): int;
+
+ /**
+ * Retrieves sync updates from a room for a given client and cursor. Updates
+ * from the specified client should be excluded.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $cursor Return updates after this cursor.
+ * @return array Sync updates.
+ */
+ public function get_updates_after_cursor( string $room, int $cursor ): array;
+
+ /**
+ * Removes updates from a room that are older than the provided cursor.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param int $cursor Remove updates with markers < this cursor.
+ * @return bool True on success, false on failure.
+ */
+ public function remove_updates_before_cursor( string $room, int $cursor ): bool;
+
+ /**
+ * Sets awareness state for a given room.
+ *
+ * @since 7.0.0
+ *
+ * @param string $room Room identifier.
+ * @param array $awareness Serializable awareness state.
+ * @return bool True on success, false on failure.
+ */
+ public function set_awareness_state( string $room, array $awareness ): bool;
+}
diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php
index cc010a7b62..301b846343 100644
--- a/wp-includes/default-filters.php
+++ b/wp-includes/default-filters.php
@@ -786,6 +786,9 @@ add_action( 'deleted_post', '_wp_after_delete_font_family', 10, 2 );
add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 );
add_action( 'init', '_wp_register_default_font_collections' );
+// Collaboration.
+add_action( 'admin_init', 'wp_collaboration_inject_setting' );
+
// Add ignoredHookedBlocks metadata attribute to the template and template part post types.
add_filter( 'rest_pre_insert_wp_template', 'inject_ignored_hooked_blocks_metadata_attributes' );
add_filter( 'rest_pre_insert_wp_template_part', 'inject_ignored_hooked_blocks_metadata_attributes' );
diff --git a/wp-includes/option.php b/wp-includes/option.php
index 8a9a2c3c89..d39cf349f2 100644
--- a/wp-includes/option.php
+++ b/wp-includes/option.php
@@ -2897,6 +2897,18 @@ function register_initial_settings() {
)
);
+ register_setting(
+ 'writing',
+ 'enable_real_time_collaboration',
+ array(
+ 'type' => 'boolean',
+ 'description' => __( 'Enable Real-Time Collaboration' ),
+ 'sanitize_callback' => 'rest_sanitize_boolean',
+ 'default' => true,
+ 'show_in_rest' => true,
+ )
+ );
+
register_setting(
'reading',
'posts_per_page',
diff --git a/wp-includes/post.php b/wp-includes/post.php
index 1b36ad58fc..eefdaafb0f 100644
--- a/wp-includes/post.php
+++ b/wp-includes/post.php
@@ -657,6 +657,41 @@ function create_initial_post_types() {
)
);
+ if ( get_option( 'enable_real_time_collaboration' ) ) {
+ register_post_type(
+ 'wp_sync_storage',
+ array(
+ 'labels' => array(
+ 'name' => __( 'Sync Updates' ),
+ 'singular_name' => __( 'Sync Update' ),
+ ),
+ 'public' => false,
+ '_builtin' => true, /* internal use only. don't use this when registering your own post type. */
+ 'hierarchical' => false,
+ 'capabilities' => array(
+ 'read' => 'do_not_allow',
+ 'read_private_posts' => 'do_not_allow',
+ 'create_posts' => 'do_not_allow',
+ 'publish_posts' => 'do_not_allow',
+ 'edit_posts' => 'do_not_allow',
+ 'edit_others_posts' => 'do_not_allow',
+ 'edit_published_posts' => 'do_not_allow',
+ 'delete_posts' => 'do_not_allow',
+ 'delete_others_posts' => 'do_not_allow',
+ 'delete_published_posts' => 'do_not_allow',
+ ),
+ 'map_meta_cap' => false,
+ 'publicly_queryable' => false,
+ 'query_var' => false,
+ 'rewrite' => false,
+ 'show_in_menu' => false,
+ 'show_in_rest' => false,
+ 'show_ui' => false,
+ 'supports' => array( 'custom-fields' ),
+ )
+ );
+ }
+
register_post_status(
'publish',
array(
@@ -8611,6 +8646,7 @@ function use_block_editor_for_post_type( $post_type ) {
* Registers any additional post meta fields.
*
* @since 6.3.0 Adds `wp_pattern_sync_status` meta field to the wp_block post type so an unsynced option can be added.
+ * @since 7.0.0 Adds `_crdt_document` meta field to post types so that CRDT documents can be persisted.
*
* @link https://github.com/WordPress/gutenberg/pull/51144
*/
@@ -8630,4 +8666,30 @@ function wp_create_initial_post_meta() {
),
)
);
+
+ if ( get_option( 'enable_real_time_collaboration' ) ) {
+ register_meta(
+ 'post',
+ '_crdt_document',
+ array(
+ 'auth_callback' => static function ( bool $_allowed, string $_meta_key, int $object_id, int $user_id ): bool {
+ return user_can( $user_id, 'edit_post', $object_id );
+ },
+ /*
+ * Revisions must be disabled because we always want to preserve
+ * the latest persisted CRDT document, even when a revision is restored.
+ * This ensures that we can continue to apply updates to a shared document
+ * and peers can simply merge the restored revision like any other incoming
+ * update.
+ *
+ * If we want to persist CRDT documents alongside revisions in the
+ * future, we should do so in a separate meta key.
+ */
+ 'revisions_enabled' => false,
+ 'show_in_rest' => true,
+ 'single' => true,
+ 'type' => 'string',
+ )
+ );
+ }
}
diff --git a/wp-includes/rest-api.php b/wp-includes/rest-api.php
index 981892025c..f144957286 100644
--- a/wp-includes/rest-api.php
+++ b/wp-includes/rest-api.php
@@ -428,6 +428,13 @@ function create_initial_rest_routes() {
// Icons.
$icons_controller = new WP_REST_Icons_Controller();
$icons_controller->register_routes();
+
+ // Collaboration.
+ if ( get_option( 'enable_real_time_collaboration' ) ) {
+ $sync_storage = new WP_Sync_Post_Meta_Storage();
+ $sync_server = new WP_HTTP_Polling_Sync_Server( $sync_storage );
+ $sync_server->register_routes();
+ }
}
/**
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
index 8cb0a4987f..b47a614c87 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php
@@ -232,7 +232,31 @@ class WP_REST_Autosaves_Controller extends WP_REST_Revisions_Controller {
$post_lock = wp_check_post_lock( $post->ID );
$is_draft = 'draft' === $post->post_status || 'auto-draft' === $post->post_status;
- if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock ) {
+ /*
+ * In the context of real-time collaboration, all peers are effectively
+ * authors and we don't want to vary behavior based on whether they are the
+ * original author. Always target an autosave revision.
+ *
+ * This avoids the following issue when real-time collaboration is enabled:
+ *
+ * - Autosaves from the original author (if they have the post lock) will
+ * target the saved post.
+ *
+ * - Autosaves from other users are applied to a post revision.
+ *
+ * - If any user reloads a post, they load changes from the author's autosave.
+ *
+ * - The saved post has now diverged from the persisted CRDT document. The
+ * content (and/or title or excerpt) are now "ahead" of the persisted CRDT
+ * document.
+ *
+ * - When the persisted CRDT document is loaded, a diff is computed against
+ * the saved post. This diff is then applied to the in-memory CRDT
+ * document, which can lead to duplicate inserts or deletions.
+ */
+ $is_collaboration_enabled = get_option( 'enable_real_time_collaboration' );
+
+ if ( $is_draft && (int) $post->post_author === $user_id && ! $post_lock && ! $is_collaboration_enabled ) {
/*
* Draft posts for the same author: autosaving updates the post and does not create a revision.
* Convert the post object to an array and add slashes, wp_update_post() expects escaped array.
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 0d5b096fdd..c7750488a3 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.0-alpha-61688';
+$wp_version = '7.0-alpha-61689';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
diff --git a/wp-settings.php b/wp-settings.php
index b437ae153b..e90be87754 100644
--- a/wp-settings.php
+++ b/wp-settings.php
@@ -300,6 +300,10 @@ require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php';
require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php';
require ABSPATH . WPINC . '/abilities-api.php';
require ABSPATH . WPINC . '/abilities.php';
+require ABSPATH . WPINC . '/collaboration/interface-wp-sync-storage.php';
+require ABSPATH . WPINC . '/collaboration/class-wp-sync-post-meta-storage.php';
+require ABSPATH . WPINC . '/collaboration/class-wp-http-polling-sync-server.php';
+require ABSPATH . WPINC . '/collaboration.php';
require ABSPATH . WPINC . '/rest-api.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php';
require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php';