You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by ha...@apache.org on 2026/04/26 07:53:31 UTC

(skywalking-banyandb) branch main updated: Schema consistency Phase 1: revision gates and barrier RPCs (#1091)

This is an automated email from the ASF dual-hosted git repository.

hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git


The following commit(s) were added to refs/heads/main by this push:
     new 69c8f4d20 Schema consistency Phase 1: revision gates and barrier RPCs (#1091)
69c8f4d20 is described below

commit 69c8f4d20ebb6532ea4c16a7ed7114dd6ec9770b
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Sun Apr 26 15:53:26 2026 +0800

    Schema consistency Phase 1: revision gates and barrier RPCs (#1091)
---
 CHANGES.md                                         |  12 +
 .../banyandb/cluster/v1/node_schema_status.proto   |  81 +++
 api/proto/banyandb/common/v1/common.proto          |   2 +
 api/proto/banyandb/database/v1/rpc.proto           |  72 ++-
 api/proto/banyandb/database/v1/schema.proto        |  14 +
 api/proto/banyandb/measure/v1/query.proto          |   8 +
 api/proto/banyandb/model/v1/write.proto            |   1 +
 api/proto/banyandb/schema/v1/barrier.proto         | 104 ++++
 api/proto/banyandb/stream/v1/query.proto           |   8 +
 api/proto/banyandb/trace/v1/query.proto            |   8 +
 banyand/liaison/grpc/barrier.go                    | 269 ++++++++++
 banyand/liaison/grpc/barrier_test.go               | 426 ++++++++++++++++
 banyand/liaison/grpc/clamp_test.go                 | 140 ++++++
 banyand/liaison/grpc/deletion.go                   |  18 +-
 banyand/liaison/grpc/deletion_test.go              |  28 +-
 banyand/liaison/grpc/discovery_test.go             | 214 ++++++++
 banyand/liaison/grpc/measure.go                    |  62 ++-
 banyand/liaison/grpc/measure_write_test.go         | 132 +++++
 banyand/liaison/grpc/query_gate_test.go            | 197 ++++++++
 banyand/liaison/grpc/registry.go                   |  83 ++--
 banyand/liaison/grpc/schema_revision.go            | 139 ++++++
 banyand/liaison/grpc/server.go                     |  22 +-
 banyand/liaison/grpc/stream.go                     |  58 ++-
 banyand/liaison/grpc/stream_write_test.go          | 132 +++++
 banyand/liaison/grpc/trace.go                      |  70 ++-
 banyand/liaison/grpc/trace_write_test.go           | 138 ++++++
 banyand/liaison/grpc/write_helpers_test.go         | 118 +++++
 banyand/measure/metadata.go                        |   7 +-
 banyand/measure/metadata_test.go                   |  17 +-
 banyand/measure/metrics.go                         |   6 +-
 banyand/measure/svc_data.go                        |   6 +-
 banyand/metadata/schema/property/cache.go          | 142 +++++-
 banyand/metadata/schema/property/cache_test.go     |  55 +++
 banyand/metadata/schema/property/client.go         | 278 +++++++++--
 banyand/metadata/schema/property/client_test.go    |  76 +--
 banyand/metadata/schema/property/converter.go      | 122 ++++-
 banyand/metadata/schema/property/converter_test.go |   5 +-
 .../metadata/schema/property/created_at_test.go    | 123 +++++
 .../schema/property/tombstone_invariant_test.go    | 103 ++++
 banyand/metadata/schema/schema.go                  |  32 +-
 banyand/metadata/schema/schemaserver/service.go    |  13 +-
 banyand/property/db/shard.go                       |  15 +-
 banyand/property/gossip/trace.go                   |   3 +-
 banyand/stream/metadata.go                         |   8 +-
 banyand/stream/metadata_test.go                    |  13 +-
 banyand/trace/metadata.go                          |   8 +-
 banyand/trace/metadata_test.go                     |  18 +-
 docs/api-reference.md                              | 547 ++++++++++++++++++++-
 docs/interacting/schema-consistency/barriers.md    | 194 ++++++++
 docs/interacting/schema-consistency/overview.md    |  68 +++
 .../schema-consistency/revision-gates.md           | 141 ++++++
 docs/interacting/schema-consistency/scenarios.md   | 278 +++++++++++
 docs/menu.yml                                      |  10 +
 pkg/meter/native/provider.go                       |   3 +-
 pkg/schema/cache.go                                | 239 ++++++---
 pkg/schema/cache_test.go                           | 170 +++++++
 pkg/schema/schema.go                               |  16 +-
 pkg/test/helpers/constant.go                       |   3 +-
 pkg/test/measure/schema.go                         |  21 +-
 pkg/test/property/schema.go                        |   2 +-
 pkg/test/replicated/measure/etcd.go                |   6 +-
 pkg/test/replicated/stream/etcd.go                 |   4 +-
 pkg/test/replicated/trace/etcd.go                  |   3 +-
 pkg/test/setup/watch_control.go                    |  42 ++
 pkg/test/stream/schema.go                          |   8 +-
 pkg/test/trace/schema.go                           |  15 +-
 test/cases/schema/apply_rollback.go                | 525 ++++++++++++++++++++
 test/cases/schema/clamp.go                         | 313 ++++++++++++
 test/cases/schema/clients.go                       | 176 +++++++
 test/cases/schema/deletion.go                      |  26 +-
 test/cases/schema/query_gate.go                    | 373 ++++++++++++++
 test/cases/schema/shape_break.go                   | 523 ++++++++++++++++++++
 test/cases/schema/tombstone_gc.go                  | 187 +++++++
 test/cases/schema/write_gate.go                    | 384 +++++++++++++++
 test/integration/distributed/schema/common.go      |   1 +
 test/integration/standalone/schema/common.go       |   3 +-
 76 files changed, 7526 insertions(+), 361 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 8ed6a0415..aee219881 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -19,6 +19,18 @@ Release Notes.
   - The `--namespace` CLI flag has been removed (it previously configured the etcd key prefix).
   - The `--node-discovery-mode` flag no longer accepts `etcd` (supported values: `none`, `dns`, `file`). 
   - The `--schema-registry-mode` flag only accepts `property`.
+- Schema consistency (Phase 1): introduce client-observable revision and propagation primitives. All gates are opt-in and zero-valued requests preserve prior behavior.
+  - Add `mod_revision` to Group / IndexRule / IndexRuleBinding / TopNAggregation Create and Update responses.
+  - Add `delete_time` to all `*ServiceDeleteResponse` messages so clients can observe tombstones.
+  - Add `created_at` to Stream / Measure / Trace / Property / IndexRule / IndexRuleBinding / TopNAggregation / Group; preserved across updates.
+  - Add `STATUS_SCHEMA_NOT_APPLIED` (10) for writes/queries whose `mod_revision` is ahead of the server cache.
+  - Add three-way write-path `ModRevision` gate on Stream and Measure write RPCs (`<` expired, `==` succeed, `>` not-applied, `0` skipped).
+  - Add per-group query-path gate via `QueryRequest.group_mod_revisions` and `QueryResponse.group_statuses` for Stream / Measure / Trace queries.
+  - Add automatic time-range clamp `max(time_range.start, schema.created_at)`; multi-group uses the maximum across queried groups; nil for pre-upgrade schemas is a no-op.
+  - Add `SchemaBarrierService` with `AwaitRevisionApplied`, `AwaitSchemaApplied`, `AwaitSchemaDeleted` (10 000-key cap, timeout-bounded, returns laggards on expiry).
+  - Add tombstone retention/GC (default 7 days, configurable via `--schema-server-tombstone-retention`) with a per-cache count cap to bound memory under bulk deletes.
+  - Reject `Create` with `updated_at <= tombstone.delete_time` to prevent replayed creates from overwriting newer deletes.
+  - Guard `pkg/schema/cache` against out-of-order `EventDelete` events; expose monotonic `LatestModRevision` watermark.
 
 ### Bug Fixes
 
diff --git a/api/proto/banyandb/cluster/v1/node_schema_status.proto b/api/proto/banyandb/cluster/v1/node_schema_status.proto
new file mode 100644
index 000000000..fe395724b
--- /dev/null
+++ b/api/proto/banyandb/cluster/v1/node_schema_status.proto
@@ -0,0 +1,81 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+syntax = "proto3";
+
+package banyandb.cluster.v1;
+
+import "banyandb/schema/v1/barrier.proto";
+
+option go_package = "github.com/apache/skywalking-banyandb/api/proto/banyandb/cluster/v1";
+
+// NodeSchemaStatusService is exposed by every data node so the liaison can
+// inspect each node's local schema cache when satisfying a SchemaBarrierService
+// request. The implementation lands in Step 2.1.
+service NodeSchemaStatusService {
+  // GetMaxRevision returns the highest mod_revision currently observed by the
+  // node's schema cache.
+  rpc GetMaxRevision(GetMaxRevisionRequest) returns (GetMaxRevisionResponse);
+  // GetKeyRevisions returns the per-key mod_revision observed by the node, and
+  // a presence flag for each key.
+  rpc GetKeyRevisions(GetKeyRevisionsRequest) returns (GetKeyRevisionsResponse);
+  // GetAbsentKeys partitions the requested keys into those the node has
+  // already removed and those that are still present.
+  rpc GetAbsentKeys(GetAbsentKeysRequest) returns (GetAbsentKeysResponse);
+}
+
+// GetMaxRevisionRequest carries no parameters; the node returns its current
+// max mod_revision.
+message GetMaxRevisionRequest {}
+
+// GetMaxRevisionResponse holds the node's current max mod_revision.
+message GetMaxRevisionResponse {
+  int64 max_mod_revision = 1;
+}
+
+// GetKeyRevisionsRequest names the keys whose per-key revisions the caller
+// wants to inspect.
+message GetKeyRevisionsRequest {
+  repeated banyandb.schema.v1.SchemaKey keys = 1;
+}
+
+// KeyRevision pairs a SchemaKey with the node's local mod_revision and
+// presence flag.
+message KeyRevision {
+  banyandb.schema.v1.SchemaKey key = 1;
+  int64 mod_revision = 2;
+  bool present = 3;
+}
+
+// GetKeyRevisionsResponse lists per-key revisions in the same order the
+// caller supplied keys.
+message GetKeyRevisionsResponse {
+  repeated KeyRevision revisions = 1;
+}
+
+// GetAbsentKeysRequest names the keys whose absence the caller wants to
+// confirm.
+message GetAbsentKeysRequest {
+  repeated banyandb.schema.v1.SchemaKey keys = 1;
+}
+
+// GetAbsentKeysResponse partitions the requested keys into absent and
+// still-present subsets.
+message GetAbsentKeysResponse {
+  repeated banyandb.schema.v1.SchemaKey absent_keys = 1;
+  repeated banyandb.schema.v1.SchemaKey still_present_keys = 2;
+}
diff --git a/api/proto/banyandb/common/v1/common.proto b/api/proto/banyandb/common/v1/common.proto
index 644b08de9..499c6632f 100644
--- a/api/proto/banyandb/common/v1/common.proto
+++ b/api/proto/banyandb/common/v1/common.proto
@@ -119,4 +119,6 @@ message Group {
   ResourceOpts resource_opts = 3;
   // updated_at indicates when resources of the group are updated
   google.protobuf.Timestamp updated_at = 4;
+  // created_at is the first-appearance timestamp; survives updates unchanged.
+  google.protobuf.Timestamp created_at = 5;
 }
diff --git a/api/proto/banyandb/database/v1/rpc.proto b/api/proto/banyandb/database/v1/rpc.proto
index d9a1cda92..a7d7a882a 100644
--- a/api/proto/banyandb/database/v1/rpc.proto
+++ b/api/proto/banyandb/database/v1/rpc.proto
@@ -52,6 +52,10 @@ message StreamRegistryServiceDeleteRequest {
 
 message StreamRegistryServiceDeleteResponse {
   bool deleted = 1;
+  // delete_time is the server-assigned tombstone timestamp in unix nanos.
+  int64 delete_time = 2;
+  // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+  int64 mod_revision = 3;
 }
 
 message StreamRegistryServiceGetRequest {
@@ -114,13 +118,19 @@ message IndexRuleBindingRegistryServiceCreateRequest {
   banyandb.database.v1.IndexRuleBinding index_rule_binding = 1;
 }
 
-message IndexRuleBindingRegistryServiceCreateResponse {}
+message IndexRuleBindingRegistryServiceCreateResponse {
+  // mod_revision is the etcd revision assigned by the server on successful create/update.
+  int64 mod_revision = 1;
+}
 
 message IndexRuleBindingRegistryServiceUpdateRequest {
   banyandb.database.v1.IndexRuleBinding index_rule_binding = 1;
 }
 
-message IndexRuleBindingRegistryServiceUpdateResponse {}
+message IndexRuleBindingRegistryServiceUpdateResponse {
+  // mod_revision is the etcd revision assigned by the server on successful create/update.
+  int64 mod_revision = 1;
+}
 
 message IndexRuleBindingRegistryServiceDeleteRequest {
   banyandb.common.v1.Metadata metadata = 1;
@@ -128,6 +138,10 @@ message IndexRuleBindingRegistryServiceDeleteRequest {
 
 message IndexRuleBindingRegistryServiceDeleteResponse {
   bool deleted = 1;
+  // delete_time is the server-assigned tombstone timestamp in unix nanos.
+  int64 delete_time = 2;
+  // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+  int64 mod_revision = 3;
 }
 
 message IndexRuleBindingRegistryServiceGetRequest {
@@ -190,13 +204,19 @@ message IndexRuleRegistryServiceCreateRequest {
   banyandb.database.v1.IndexRule index_rule = 1;
 }
 
-message IndexRuleRegistryServiceCreateResponse {}
+message IndexRuleRegistryServiceCreateResponse {
+  // mod_revision is the etcd revision assigned by the server on successful create/update.
+  int64 mod_revision = 1;
+}
 
 message IndexRuleRegistryServiceUpdateRequest {
   banyandb.database.v1.IndexRule index_rule = 1;
 }
 
-message IndexRuleRegistryServiceUpdateResponse {}
+message IndexRuleRegistryServiceUpdateResponse {
+  // mod_revision is the etcd revision assigned by the server on successful create/update.
+  int64 mod_revision = 1;
+}
 
 message IndexRuleRegistryServiceDeleteRequest {
   banyandb.common.v1.Metadata metadata = 1;
@@ -204,6 +224,10 @@ message IndexRuleRegistryServiceDeleteRequest {
 
 message IndexRuleRegistryServiceDeleteResponse {
   bool deleted = 1;
+  // delete_time is the server-assigned tombstone timestamp in unix nanos.
+  int64 delete_time = 2;
+  // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+  int64 mod_revision = 3;
 }
 
 message IndexRuleRegistryServiceGetRequest {
@@ -284,6 +308,10 @@ message MeasureRegistryServiceDeleteRequest {
 
 message MeasureRegistryServiceDeleteResponse {
   bool deleted = 1;
+  // delete_time is the server-assigned tombstone timestamp in unix nanos.
+  int64 delete_time = 2;
+  // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+  int64 mod_revision = 3;
 }
 
 message MeasureRegistryServiceGetRequest {
@@ -346,13 +374,19 @@ message GroupRegistryServiceCreateRequest {
   banyandb.common.v1.Group group = 1;
 }
 
-message GroupRegistryServiceCreateResponse {}
+message GroupRegistryServiceCreateResponse {
+  // mod_revision is the etcd revision assigned by the server on successful create/update.
+  int64 mod_revision = 1;
+}
 
 message GroupRegistryServiceUpdateRequest {
   banyandb.common.v1.Group group = 1;
 }
 
-message GroupRegistryServiceUpdateResponse {}
+message GroupRegistryServiceUpdateResponse {
+  // mod_revision is the etcd revision assigned by the server on successful create/update.
+  int64 mod_revision = 1;
+}
 
 // GroupRegistryServiceDeleteRequest is the request for deleting a group.
 message GroupRegistryServiceDeleteRequest {
@@ -373,6 +407,10 @@ message GroupRegistryServiceDeleteRequest {
 message GroupRegistryServiceDeleteResponse {
   // schema_info contains the schema resources that would be deleted (populated in dry-run mode).
   SchemaInfo schema_info = 1;
+  // delete_time is the server-assigned tombstone timestamp in unix nanos.
+  int64 delete_time = 2;
+  // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+  int64 mod_revision = 3;
 }
 
 // GroupDeletionTask represents the status of a group deletion operation.
@@ -604,13 +642,19 @@ message TopNAggregationRegistryServiceCreateRequest {
   banyandb.database.v1.TopNAggregation top_n_aggregation = 1;
 }
 
-message TopNAggregationRegistryServiceCreateResponse {}
+message TopNAggregationRegistryServiceCreateResponse {
+  // mod_revision is the etcd revision assigned by the server on successful create/update.
+  int64 mod_revision = 1;
+}
 
 message TopNAggregationRegistryServiceUpdateRequest {
   banyandb.database.v1.TopNAggregation top_n_aggregation = 1;
 }
 
-message TopNAggregationRegistryServiceUpdateResponse {}
+message TopNAggregationRegistryServiceUpdateResponse {
+  // mod_revision is the etcd revision assigned by the server on successful create/update.
+  int64 mod_revision = 1;
+}
 
 message TopNAggregationRegistryServiceDeleteRequest {
   banyandb.common.v1.Metadata metadata = 1;
@@ -618,6 +662,10 @@ message TopNAggregationRegistryServiceDeleteRequest {
 
 message TopNAggregationRegistryServiceDeleteResponse {
   bool deleted = 1;
+  // delete_time is the server-assigned tombstone timestamp in unix nanos.
+  int64 delete_time = 2;
+  // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+  int64 mod_revision = 3;
 }
 
 message TopNAggregationRegistryServiceGetRequest {
@@ -723,6 +771,10 @@ message PropertyRegistryServiceDeleteRequest {
 
 message PropertyRegistryServiceDeleteResponse {
   bool deleted = 1;
+  // delete_time is the server-assigned tombstone timestamp in unix nanos.
+  int64 delete_time = 2;
+  // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+  int64 mod_revision = 3;
 }
 
 message PropertyRegistryServiceGetRequest {
@@ -803,6 +855,10 @@ message TraceRegistryServiceDeleteRequest {
 
 message TraceRegistryServiceDeleteResponse {
   bool deleted = 1;
+  // delete_time is the server-assigned tombstone timestamp in unix nanos.
+  int64 delete_time = 2;
+  // mod_revision is the etcd revision of the tombstone; zero if the server did not record one.
+  int64 mod_revision = 3;
 }
 
 message TraceRegistryServiceGetRequest {
diff --git a/api/proto/banyandb/database/v1/schema.proto b/api/proto/banyandb/database/v1/schema.proto
index d23594184..33fd3abbc 100644
--- a/api/proto/banyandb/database/v1/schema.proto
+++ b/api/proto/banyandb/database/v1/schema.proto
@@ -59,6 +59,8 @@ message Stream {
   Entity entity = 3 [(validate.rules).message.required = true];
   // updated_at indicates when the stream is updated
   google.protobuf.Timestamp updated_at = 4;
+  // created_at is the first-appearance timestamp; survives updates unchanged.
+  google.protobuf.Timestamp created_at = 5;
 }
 
 message Entity {
@@ -119,6 +121,8 @@ message Measure {
   bool index_mode = 7;
   // sharding_key determines the distribution of TopN-related data.
   ShardingKey sharding_key = 8;
+  // created_at is the first-appearance timestamp; survives updates unchanged.
+  google.protobuf.Timestamp created_at = 9;
 }
 
 // TopNAggregation generates offline TopN statistics for a measure's TopN approximation
@@ -145,6 +149,8 @@ message TopNAggregation {
   int32 lru_size = 8;
   // updated_at indicates when the measure is updated
   google.protobuf.Timestamp updated_at = 9;
+  // created_at is the first-appearance timestamp; survives updates unchanged.
+  google.protobuf.Timestamp created_at = 10;
 }
 
 // IndexRule defines how to generate indices based on tags and the index type
@@ -180,6 +186,8 @@ message IndexRule {
   string analyzer = 5;
   // no_sort indicates whether the index is not for sorting.
   bool no_sort = 6;
+  // created_at is the first-appearance timestamp; survives updates unchanged.
+  google.protobuf.Timestamp created_at = 7;
 }
 
 // Subject defines which stream or measure would generate indices
@@ -208,6 +216,8 @@ message IndexRuleBinding {
   google.protobuf.Timestamp expire_at = 5 [(validate.rules).timestamp.required = true];
   // updated_at indicates when the IndexRuleBinding is updated
   google.protobuf.Timestamp updated_at = 6;
+  // created_at is the first-appearance timestamp; survives updates unchanged.
+  google.protobuf.Timestamp created_at = 7;
 }
 
 // Property stores the user defined data
@@ -218,6 +228,8 @@ message Property {
   repeated TagSpec tags = 2 [(validate.rules).repeated.min_items = 1];
   // updated_at indicates when the property is updated
   google.protobuf.Timestamp updated_at = 6;
+  // created_at is the first-appearance timestamp; survives updates unchanged.
+  google.protobuf.Timestamp created_at = 7;
 }
 
 // TraceTagSpec defines the specification of a tag in a trace.
@@ -245,4 +257,6 @@ message Trace {
   google.protobuf.Timestamp updated_at = 5;
   // span_id_tag_name is the name of the tag that stores the span ID.
   string span_id_tag_name = 6 [(validate.rules).string.min_len = 1];
+  // created_at is the first-appearance timestamp; survives updates unchanged.
+  google.protobuf.Timestamp created_at = 7;
 }
diff --git a/api/proto/banyandb/measure/v1/query.proto b/api/proto/banyandb/measure/v1/query.proto
index e518762cc..2caceaaac 100644
--- a/api/proto/banyandb/measure/v1/query.proto
+++ b/api/proto/banyandb/measure/v1/query.proto
@@ -22,6 +22,7 @@ package banyandb.measure.v1;
 import "banyandb/common/v1/trace.proto";
 import "banyandb/model/v1/common.proto";
 import "banyandb/model/v1/query.proto";
+import "banyandb/model/v1/write.proto";
 import "google/protobuf/timestamp.proto";
 import "validate/validate.proto";
 
@@ -53,6 +54,9 @@ message QueryResponse {
   repeated DataPoint data_points = 1;
   // trace contains the trace information of the query when trace is enabled
   common.v1.Trace trace = 2;
+  // group_statuses reports the per-group gate outcome. Populated even when the query
+  // short-circuits because any group failed the gate.
+  map<string, model.v1.Status> group_statuses = 50;
 }
 
 // InternalDataPoint wraps DataPoint with shard information for internal use.
@@ -142,4 +146,8 @@ message QueryRequest {
   repeated string stages = 14;
   // rewrite_agg_top_n_result will rewrite agg result to raw data
   bool rewrite_agg_top_n_result = 15;
+  // group_mod_revisions gates the query per group. Keys match entries in `groups`;
+  // values are the client's known mod_revision for that group. Empty map or value 0
+  // means "don't gate". A group not listed in the map is not gated.
+  map<string, int64> group_mod_revisions = 50;
 }
diff --git a/api/proto/banyandb/model/v1/write.proto b/api/proto/banyandb/model/v1/write.proto
index 3c7a35040..20f76c790 100644
--- a/api/proto/banyandb/model/v1/write.proto
+++ b/api/proto/banyandb/model/v1/write.proto
@@ -34,4 +34,5 @@ enum Status {
   STATUS_VERSION_UNSUPPORTED = 7; // Client version not supported
   STATUS_VERSION_DEPRECATED = 8; // Client version deprecated but still supported
   STATUS_METADATA_REQUIRED = 9; // Metadata is required for the first request
+  STATUS_SCHEMA_NOT_APPLIED = 10; // Client's ModRevision is ahead of the server cache; the server waited and timed out.
 }
diff --git a/api/proto/banyandb/schema/v1/barrier.proto b/api/proto/banyandb/schema/v1/barrier.proto
new file mode 100644
index 000000000..8150d3051
--- /dev/null
+++ b/api/proto/banyandb/schema/v1/barrier.proto
@@ -0,0 +1,104 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+syntax = "proto3";
+
+package banyandb.schema.v1;
+
+import "google/protobuf/duration.proto";
+
+option go_package = "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1";
+option java_package = "org.apache.skywalking.banyandb.schema.v1";
+
+// SchemaBarrierService lets clients block until every data node in the cluster
+// has caught up to a target schema state. The standalone implementation lands
+// in Step 1.8; the distributed liaison fan-out lands in Steps 1.15-1.17.
+service SchemaBarrierService {
+  // AwaitRevisionApplied blocks until every data node's local schema cache has
+  // observed mod_revision >= min_revision, or the timeout elapses.
+  rpc AwaitRevisionApplied(AwaitRevisionAppliedRequest) returns (AwaitRevisionAppliedResponse);
+  // AwaitSchemaApplied blocks until every data node reports all requested keys
+  // present at or above the per-key mod_revision.
+  rpc AwaitSchemaApplied(AwaitSchemaAppliedRequest) returns (AwaitSchemaAppliedResponse);
+  // AwaitSchemaDeleted blocks until every data node has removed all requested
+  // keys from its cache.
+  rpc AwaitSchemaDeleted(AwaitSchemaDeletedRequest) returns (AwaitSchemaDeletedResponse);
+}
+
+// SchemaKey identifies a schema resource by kind and name. Valid kind values:
+// "measure", "stream", "trace", "property", "index_rule",
+// "index_rule_binding", "group", "top_n_aggregation".
+message SchemaKey {
+  string kind = 1;
+  string group = 2;
+  string name = 3;
+}
+
+// AwaitRevisionAppliedRequest carries the minimum mod_revision the caller is
+// waiting for and a wall-clock budget for the wait.
+message AwaitRevisionAppliedRequest {
+  int64 min_revision = 1;
+  google.protobuf.Duration timeout = 2;
+}
+
+// NodeLaggard reports a single data node that has not caught up to the
+// requested schema state. missing_keys is populated by AwaitSchemaApplied
+// responses; still_present_keys is populated by AwaitSchemaDeleted responses.
+message NodeLaggard {
+  string node = 1;
+  int64 current_mod_revision = 2;
+  repeated SchemaKey missing_keys = 3;
+  repeated SchemaKey still_present_keys = 4;
+}
+
+// AwaitRevisionAppliedResponse reports whether every node reached the target
+// revision before the timeout, and lists laggards otherwise.
+message AwaitRevisionAppliedResponse {
+  bool applied = 1;
+  repeated NodeLaggard laggards = 2;
+}
+
+// AwaitSchemaAppliedRequest pairs each key with a per-key minimum revision.
+// keys is capped at 10000 server-side; exceeding the cap returns
+// InvalidArgument. A min_revisions entry of 0 means "just present, any
+// revision".
+message AwaitSchemaAppliedRequest {
+  repeated SchemaKey keys = 1;
+  repeated int64 min_revisions = 2;
+  google.protobuf.Duration timeout = 3;
+}
+
+// AwaitSchemaAppliedResponse reports whether every requested key is present at
+// or above its target revision on every node.
+message AwaitSchemaAppliedResponse {
+  bool applied = 1;
+  repeated NodeLaggard laggards = 2;
+}
+
+// AwaitSchemaDeletedRequest names the keys that must disappear from every
+// node's cache before the call returns.
+message AwaitSchemaDeletedRequest {
+  repeated SchemaKey keys = 1;
+  google.protobuf.Duration timeout = 2;
+}
+
+// AwaitSchemaDeletedResponse reports whether every node has removed the
+// requested keys.
+message AwaitSchemaDeletedResponse {
+  bool applied = 1;
+  repeated NodeLaggard laggards = 2;
+}
diff --git a/api/proto/banyandb/stream/v1/query.proto b/api/proto/banyandb/stream/v1/query.proto
index ae23dd335..71dbcc5b1 100644
--- a/api/proto/banyandb/stream/v1/query.proto
+++ b/api/proto/banyandb/stream/v1/query.proto
@@ -21,6 +21,7 @@ package banyandb.stream.v1;
 
 import "banyandb/common/v1/trace.proto";
 import "banyandb/model/v1/query.proto";
+import "banyandb/model/v1/write.proto";
 import "google/protobuf/timestamp.proto";
 import "validate/validate.proto";
 
@@ -52,6 +53,9 @@ message QueryResponse {
   repeated Element elements = 1;
   // trace contains the trace information of the query when trace is enabled
   common.v1.Trace trace = 2;
+  // group_statuses reports the per-group gate outcome. Populated even when the query
+  // short-circuits because any group failed the gate.
+  map<string, model.v1.Status> group_statuses = 50;
 }
 
 // QueryRequest is the request contract for query.
@@ -79,4 +83,8 @@ message QueryRequest {
   bool trace = 9;
   // stage is used to specify the stage of the query in the lifecycle
   repeated string stages = 10;
+  // group_mod_revisions gates the query per group. Keys match entries in `groups`;
+  // values are the client's known mod_revision for that group. Empty map or value 0
+  // means "don't gate". A group not listed in the map is not gated.
+  map<string, int64> group_mod_revisions = 50;
 }
diff --git a/api/proto/banyandb/trace/v1/query.proto b/api/proto/banyandb/trace/v1/query.proto
index 97ed0fe2b..c105ff88d 100644
--- a/api/proto/banyandb/trace/v1/query.proto
+++ b/api/proto/banyandb/trace/v1/query.proto
@@ -21,6 +21,7 @@ package banyandb.trace.v1;
 
 import "banyandb/common/v1/trace.proto";
 import "banyandb/model/v1/query.proto";
+import "banyandb/model/v1/write.proto";
 import "validate/validate.proto";
 
 option go_package = "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1";
@@ -60,6 +61,9 @@ message QueryResponse {
   repeated Trace traces = 1;
   // trace_query_result contains the trace of the query execution if tracing is enabled.
   common.v1.Trace trace_query_result = 2;
+  // group_statuses reports the per-group gate outcome. Populated even when the query
+  // short-circuits because any group failed the gate.
+  map<string, model.v1.Status> group_statuses = 50;
 }
 
 // InternalQueryResponse is the response of an internal query.
@@ -94,4 +98,8 @@ message QueryRequest {
   bool trace = 9;
   // stage is used to specify the stage of the query in the lifecycle
   repeated string stages = 10;
+  // group_mod_revisions gates the query per group. Keys match entries in `groups`;
+  // values are the client's known mod_revision for that group. Empty map or value 0
+  // means "don't gate". A group not listed in the map is not gated.
+  map<string, int64> group_mod_revisions = 50;
 }
diff --git a/banyand/liaison/grpc/barrier.go b/banyand/liaison/grpc/barrier.go
new file mode 100644
index 000000000..8e135a2e8
--- /dev/null
+++ b/banyand/liaison/grpc/barrier.go
@@ -0,0 +1,269 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"context"
+	"time"
+
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+)
+
+const (
+	barrierMaxKeys      = 10000
+	barrierInitInterval = 10 * time.Millisecond
+	barrierMaxInterval  = 500 * time.Millisecond
+	barrierGrowthFactor = 1.5
+	// barrierDefaultTimeout bounds calls that pass a nil/zero timeout. It must
+	// be large enough to cover normal schema propagation (watch event delivery
+	// + handler dispatch + tsdb open) so a no-timeout caller does not see false
+	// applied=false. Distinct from barrierMaxInterval, which only caps the poll
+	// backoff between probes.
+	barrierDefaultTimeout = 5 * time.Second
+)
+
+// barrierCacheReader abstracts the property schema cache so the barrier service
+// can poll for mod-revision progress without coupling to a concrete type.
+type barrierCacheReader interface {
+	GetMaxModRevision() int64
+	GetKeyModRevision(propID string) (int64, bool)
+}
+
+type barrierService struct {
+	schemav1.UnimplementedSchemaBarrierServiceServer
+	cacheProvider func() barrierCacheReader
+}
+
+func newBarrierService(cacheProvider func() barrierCacheReader) *barrierService {
+	return &barrierService{cacheProvider: cacheProvider}
+}
+
+// cache resolves the current barrier cache reader. It returns nil if the
+// underlying schema registry is not yet initialized.
+func (b *barrierService) cache() barrierCacheReader {
+	if b.cacheProvider == nil {
+		return nil
+	}
+	return b.cacheProvider()
+}
+
+// AwaitRevisionApplied blocks until the cache's max modRevision is >= req.MinRevision
+// or the timeout elapses. In standalone mode there is one node, so Laggards carries a
+// single entry whose current_mod_revision reports the cache watermark — this lets
+// callers diagnose how far behind the standalone cache is even when applied=false.
+func (b *barrierService) AwaitRevisionApplied(ctx context.Context, req *schemav1.AwaitRevisionAppliedRequest) (*schemav1.AwaitRevisionAppliedResponse, error) {
+	deadline := time.Now().Add(barrierDeadlineDuration(req.GetTimeout()))
+	pollCtx, cancel := context.WithDeadline(ctx, deadline)
+	defer cancel()
+	interval := barrierInitInterval
+	currentRevision := func() int64 {
+		if c := b.cache(); c != nil {
+			return c.GetMaxModRevision()
+		}
+		return 0
+	}
+	for {
+		if currentRevision() >= req.GetMinRevision() {
+			return &schemav1.AwaitRevisionAppliedResponse{Applied: true}, nil
+		}
+		if time.Now().After(deadline) {
+			return &schemav1.AwaitRevisionAppliedResponse{
+				Applied:  false,
+				Laggards: []*schemav1.NodeLaggard{{CurrentModRevision: currentRevision()}},
+			}, nil
+		}
+		select {
+		case <-time.After(interval):
+		case <-pollCtx.Done():
+			return &schemav1.AwaitRevisionAppliedResponse{
+				Applied:  false,
+				Laggards: []*schemav1.NodeLaggard{{CurrentModRevision: currentRevision()}},
+			}, nil
+		}
+		interval = barrierBackoff(interval)
+	}
+}
+
+// AwaitSchemaApplied blocks until all requested keys are present at or above their
+// per-key min_revisions, or the timeout elapses.
+func (b *barrierService) AwaitSchemaApplied(ctx context.Context, req *schemav1.AwaitSchemaAppliedRequest) (*schemav1.AwaitSchemaAppliedResponse, error) {
+	if len(req.GetKeys()) > barrierMaxKeys {
+		return nil, status.Errorf(codes.InvalidArgument, "too many keys: max=%d", barrierMaxKeys)
+	}
+	deadline := time.Now().Add(barrierDeadlineDuration(req.GetTimeout()))
+	pollCtx, cancel := context.WithDeadline(ctx, deadline)
+	defer cancel()
+	interval := barrierInitInterval
+	for {
+		missing := b.collectMissingKeys(req.GetKeys(), req.GetMinRevisions())
+		if len(missing) == 0 {
+			return &schemav1.AwaitSchemaAppliedResponse{Applied: true}, nil
+		}
+		if time.Now().After(deadline) {
+			return &schemav1.AwaitSchemaAppliedResponse{
+				Applied:  false,
+				Laggards: []*schemav1.NodeLaggard{{MissingKeys: missing}},
+			}, nil
+		}
+		select {
+		case <-time.After(interval):
+		case <-pollCtx.Done():
+			return &schemav1.AwaitSchemaAppliedResponse{
+				Applied:  false,
+				Laggards: []*schemav1.NodeLaggard{{MissingKeys: missing}},
+			}, nil
+		}
+		interval = barrierBackoff(interval)
+	}
+}
+
+// AwaitSchemaDeleted blocks until all requested keys are absent from the cache,
+// or the timeout elapses.
+func (b *barrierService) AwaitSchemaDeleted(ctx context.Context, req *schemav1.AwaitSchemaDeletedRequest) (*schemav1.AwaitSchemaDeletedResponse, error) {
+	if len(req.GetKeys()) > barrierMaxKeys {
+		return nil, status.Errorf(codes.InvalidArgument, "too many keys: max=%d", barrierMaxKeys)
+	}
+	deadline := time.Now().Add(barrierDeadlineDuration(req.GetTimeout()))
+	pollCtx, cancel := context.WithDeadline(ctx, deadline)
+	defer cancel()
+	interval := barrierInitInterval
+	for {
+		stillPresent := b.collectPresentKeys(req.GetKeys())
+		if len(stillPresent) == 0 {
+			return &schemav1.AwaitSchemaDeletedResponse{Applied: true}, nil
+		}
+		if time.Now().After(deadline) {
+			return &schemav1.AwaitSchemaDeletedResponse{
+				Applied:  false,
+				Laggards: []*schemav1.NodeLaggard{{StillPresentKeys: stillPresent}},
+			}, nil
+		}
+		select {
+		case <-time.After(interval):
+		case <-pollCtx.Done():
+			return &schemav1.AwaitSchemaDeletedResponse{
+				Applied:  false,
+				Laggards: []*schemav1.NodeLaggard{{StillPresentKeys: stillPresent}},
+			}, nil
+		}
+		interval = barrierBackoff(interval)
+	}
+}
+
+// collectMissingKeys returns the subset of keys that are not yet present in the
+// cache at or above their required mod_revision. When the cache is not yet
+// initialized, all keys are reported as missing so polling continues.
+func (b *barrierService) collectMissingKeys(keys []*schemav1.SchemaKey, minRevisions []int64) []*schemav1.SchemaKey {
+	c := b.cache()
+	if c == nil {
+		missing := make([]*schemav1.SchemaKey, len(keys))
+		copy(missing, keys)
+		return missing
+	}
+	var missing []*schemav1.SchemaKey
+	for idx, key := range keys {
+		propID := schemaKeyToPropID(key)
+		rev, ok := c.GetKeyModRevision(propID)
+		var minRev int64
+		if idx < len(minRevisions) {
+			minRev = minRevisions[idx]
+		}
+		if !ok || (minRev > 0 && rev < minRev) {
+			missing = append(missing, key)
+		}
+	}
+	return missing
+}
+
+// collectPresentKeys returns the subset of keys that are still present in the cache.
+// When the cache is not yet initialized, all keys are reported as still-present
+// to avoid falsely claiming successful deletion before the cache is online.
+func (b *barrierService) collectPresentKeys(keys []*schemav1.SchemaKey) []*schemav1.SchemaKey {
+	c := b.cache()
+	if c == nil {
+		present := make([]*schemav1.SchemaKey, len(keys))
+		copy(present, keys)
+		return present
+	}
+	var present []*schemav1.SchemaKey
+	for _, key := range keys {
+		propID := schemaKeyToPropID(key)
+		if _, ok := c.GetKeyModRevision(propID); ok {
+			present = append(present, key)
+		}
+	}
+	return present
+}
+
+// schemaKeyToPropID converts a SchemaKey to the property-ID string format used
+// by the schema cache. The propID format mirrors property.BuildPropertyID:
+// kindString + "_" + group + "/" + name (or kindString + "_" + name for groups).
+// SchemaKey.Kind uses proto underscore_case; kind.String() uses camelCase for compound
+// kinds, so this function normalises the mapping.
+func schemaKeyToPropID(key *schemav1.SchemaKey) string {
+	kindStr := protoKindToString(key.GetKind())
+	if key.GetKind() == "group" {
+		return kindStr + "_" + key.GetName()
+	}
+	return kindStr + "_" + key.GetGroup() + "/" + key.GetName()
+}
+
+// protoKindToString maps the proto-style kind string from SchemaKey (underscore_case) to
+// the value returned by schema.Kind.String() (camelCase for compound kinds).
+func protoKindToString(protoKind string) string {
+	switch protoKind {
+	case "index_rule":
+		return schema.KindIndexRule.String()
+	case "index_rule_binding":
+		return schema.KindIndexRuleBinding.String()
+	case "top_n_aggregation":
+		return schema.KindTopNAggregation.String()
+	default:
+		// For "stream", "measure", "trace", "group", "property", "node" the
+		// proto kind string already matches kind.String().
+		return protoKind
+	}
+}
+
+// barrierBackoff returns the next poll interval, capped at barrierMaxInterval.
+func barrierBackoff(d time.Duration) time.Duration {
+	next := time.Duration(float64(d) * barrierGrowthFactor)
+	if next > barrierMaxInterval {
+		return barrierMaxInterval
+	}
+	return next
+}
+
+// barrierDeadlineDuration returns the effective wait duration from a proto Duration.
+// A nil or zero timeout maps to barrierDefaultTimeout so callers that omit the field
+// get a realistic propagation budget rather than the poll-interval cap.
+func barrierDeadlineDuration(d *durationpb.Duration) time.Duration {
+	if d == nil {
+		return barrierDefaultTimeout
+	}
+	dur := d.AsDuration()
+	if dur <= 0 {
+		return barrierDefaultTimeout
+	}
+	return dur
+}
diff --git a/banyand/liaison/grpc/barrier_test.go b/banyand/liaison/grpc/barrier_test.go
new file mode 100644
index 000000000..a6214db33
--- /dev/null
+++ b/banyand/liaison/grpc/barrier_test.go
@@ -0,0 +1,426 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"context"
+	"maps"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+)
+
+// Mock cache helpers follow.
+
+// staticBarrierCache implements barrierCacheReader with fixed data — no advancing.
+type staticBarrierCache struct {
+	keys           map[string]int64
+	maxModRevision int64
+}
+
+func (c *staticBarrierCache) GetMaxModRevision() int64 { return c.maxModRevision }
+func (c *staticBarrierCache) GetKeyModRevision(propID string) (int64, bool) {
+	rev, ok := c.keys[propID]
+	return rev, ok
+}
+
+// advancingBarrierCache implements barrierCacheReader where individual fields can
+// be updated concurrently (used to simulate cache catch-up).
+type advancingBarrierCache struct {
+	keys   map[string]int64
+	maxRev int64
+	mu     sync.RWMutex
+}
+
+func newAdvancingCache(maxRev int64, keys map[string]int64) *advancingBarrierCache {
+	copied := make(map[string]int64, len(keys))
+	maps.Copy(copied, keys)
+	return &advancingBarrierCache{maxRev: maxRev, keys: copied}
+}
+
+func (c *advancingBarrierCache) GetMaxModRevision() int64 {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.maxRev
+}
+
+func (c *advancingBarrierCache) GetKeyModRevision(propID string) (int64, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	rev, ok := c.keys[propID]
+	return rev, ok
+}
+
+func (c *advancingBarrierCache) setMaxRev(rev int64) {
+	c.mu.Lock()
+	c.maxRev = rev
+	c.mu.Unlock()
+}
+
+func (c *advancingBarrierCache) addKey(propID string, rev int64) {
+	c.mu.Lock()
+	c.keys[propID] = rev
+	c.mu.Unlock()
+}
+
+func (c *advancingBarrierCache) removeKey(propID string) {
+	c.mu.Lock()
+	delete(c.keys, propID)
+	c.mu.Unlock()
+}
+
+// schemaKey is a convenience constructor.
+func schemaKey(kind, group, name string) *schemav1.SchemaKey {
+	return &schemav1.SchemaKey{Kind: kind, Group: group, Name: name}
+}
+
+// AwaitRevisionApplied tests follow.
+
+// TestBarrier_AwaitRevisionApplied_AlreadyAtRevision verifies that when the
+// cache is already at or above the requested min_revision, Applied=true is
+// returned immediately without any polling.
+func TestBarrier_AwaitRevisionApplied_AlreadyAtRevision(t *testing.T) {
+	svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{maxModRevision: 100} })
+	resp, rpcErr := svc.AwaitRevisionApplied(context.Background(), &schemav1.AwaitRevisionAppliedRequest{
+		MinRevision: 100,
+		Timeout:     durationpb.New(50 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.True(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitRevisionApplied_NeverReaches_ReturnsFalse verifies that when
+// the cache never advances to the required revision, Applied=false is returned
+// after the timeout elapses.
+func TestBarrier_AwaitRevisionApplied_NeverReaches_ReturnsFalse(t *testing.T) {
+	svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{maxModRevision: 50} })
+	resp, rpcErr := svc.AwaitRevisionApplied(context.Background(), &schemav1.AwaitRevisionAppliedRequest{
+		MinRevision: 99999,
+		Timeout:     durationpb.New(60 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.False(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitRevisionApplied_CacheAdvances_ReturnsTrue verifies that when
+// the cache advances to the required revision within the timeout window, Applied=true
+// is returned as soon as the advancement is detected.
+func TestBarrier_AwaitRevisionApplied_CacheAdvances_ReturnsTrue(t *testing.T) {
+	cache := newAdvancingCache(50, nil)
+	svc := newBarrierService(func() barrierCacheReader { return cache })
+	go func() {
+		time.Sleep(20 * time.Millisecond)
+		cache.setMaxRev(200)
+	}()
+	resp, rpcErr := svc.AwaitRevisionApplied(context.Background(), &schemav1.AwaitRevisionAppliedRequest{
+		MinRevision: 200,
+		Timeout:     durationpb.New(500 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.True(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitRevisionApplied_ContextCancelled_ReturnsFalse verifies that
+// when the caller's context is canceled before the revision is reached, the RPC
+// returns Applied=false without error.
+func TestBarrier_AwaitRevisionApplied_ContextCancelled_ReturnsFalse(t *testing.T) {
+	svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{maxModRevision: 1} })
+	cancelCtx, cancel := context.WithCancel(context.Background())
+	go func() {
+		time.Sleep(20 * time.Millisecond)
+		cancel()
+	}()
+	resp, rpcErr := svc.AwaitRevisionApplied(cancelCtx, &schemav1.AwaitRevisionAppliedRequest{
+		MinRevision: 99999,
+		Timeout:     durationpb.New(500 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.False(t, resp.GetApplied())
+}
+
+// AwaitSchemaApplied tests follow.
+
+// TestBarrier_AwaitSchemaApplied_AllKeysPresent_ReturnsTrue verifies that when
+// all requested schema keys are already in the cache, Applied=true is returned immediately.
+func TestBarrier_AwaitSchemaApplied_AllKeysPresent_ReturnsTrue(t *testing.T) {
+	cache := &staticBarrierCache{keys: map[string]int64{"stream_g/s": 10}}
+	svc := newBarrierService(func() barrierCacheReader { return cache })
+	resp, rpcErr := svc.AwaitSchemaApplied(context.Background(), &schemav1.AwaitSchemaAppliedRequest{
+		Keys:    []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+		Timeout: durationpb.New(50 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.True(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitSchemaApplied_MissingKey_TimesOut_ReturnsFalse verifies that
+// when a required key is absent from the cache, Applied=false is returned with
+// the missing key in the Laggards list after the timeout.
+func TestBarrier_AwaitSchemaApplied_MissingKey_TimesOut_ReturnsFalse(t *testing.T) {
+	svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{keys: map[string]int64{}} })
+	resp, rpcErr := svc.AwaitSchemaApplied(context.Background(), &schemav1.AwaitSchemaAppliedRequest{
+		Keys:    []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+		Timeout: durationpb.New(60 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.False(t, resp.GetApplied())
+	require.Len(t, resp.GetLaggards(), 1)
+	assert.Len(t, resp.GetLaggards()[0].GetMissingKeys(), 1)
+}
+
+// TestBarrier_AwaitSchemaApplied_TooManyKeys_ReturnsInvalidArgument verifies that
+// requests with more than barrierMaxKeys entries are rejected with InvalidArgument.
+func TestBarrier_AwaitSchemaApplied_TooManyKeys_ReturnsInvalidArgument(t *testing.T) {
+	svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{keys: map[string]int64{}} })
+	keys := make([]*schemav1.SchemaKey, barrierMaxKeys+1)
+	for i := range keys {
+		keys[i] = schemaKey("stream", "g", "s")
+	}
+	_, rpcErr := svc.AwaitSchemaApplied(context.Background(), &schemav1.AwaitSchemaAppliedRequest{
+		Keys:    keys,
+		Timeout: durationpb.New(50 * time.Millisecond),
+	})
+	require.Error(t, rpcErr)
+	st, _ := status.FromError(rpcErr)
+	assert.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+// TestBarrier_AwaitSchemaApplied_KeyAppearsInTime_ReturnsTrue verifies that when
+// the missing key appears in the cache before the timeout, Applied=true is returned.
+func TestBarrier_AwaitSchemaApplied_KeyAppearsInTime_ReturnsTrue(t *testing.T) {
+	cache := newAdvancingCache(0, map[string]int64{})
+	svc := newBarrierService(func() barrierCacheReader { return cache })
+	go func() {
+		time.Sleep(20 * time.Millisecond)
+		cache.addKey("stream_g/s", 10)
+	}()
+	resp, rpcErr := svc.AwaitSchemaApplied(context.Background(), &schemav1.AwaitSchemaAppliedRequest{
+		Keys:    []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+		Timeout: durationpb.New(500 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.True(t, resp.GetApplied())
+}
+
+// AwaitSchemaDeleted tests follow.
+
+// TestBarrier_AwaitSchemaDeleted_AllKeysAbsent_ReturnsTrue verifies that when all
+// requested keys are already absent from the cache, Applied=true is returned immediately.
+func TestBarrier_AwaitSchemaDeleted_AllKeysAbsent_ReturnsTrue(t *testing.T) {
+	svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{keys: map[string]int64{}} })
+	resp, rpcErr := svc.AwaitSchemaDeleted(context.Background(), &schemav1.AwaitSchemaDeletedRequest{
+		Keys:    []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+		Timeout: durationpb.New(50 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.True(t, resp.GetApplied())
+}
+
+// TestBarrier_AwaitSchemaDeleted_KeyPresent_TimesOut_ReturnsFalse verifies that
+// when a key is still in the cache at timeout, Applied=false is returned with the
+// key in the Laggards list.
+func TestBarrier_AwaitSchemaDeleted_KeyPresent_TimesOut_ReturnsFalse(t *testing.T) {
+	cache := &staticBarrierCache{keys: map[string]int64{"stream_g/s": 10}}
+	svc := newBarrierService(func() barrierCacheReader { return cache })
+	resp, rpcErr := svc.AwaitSchemaDeleted(context.Background(), &schemav1.AwaitSchemaDeletedRequest{
+		Keys:    []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+		Timeout: durationpb.New(60 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.False(t, resp.GetApplied())
+	require.Len(t, resp.GetLaggards(), 1)
+	assert.Len(t, resp.GetLaggards()[0].GetStillPresentKeys(), 1)
+}
+
+// TestBarrier_AwaitSchemaDeleted_TooManyKeys_ReturnsInvalidArgument verifies
+// that requests with more than barrierMaxKeys entries are rejected.
+func TestBarrier_AwaitSchemaDeleted_TooManyKeys_ReturnsInvalidArgument(t *testing.T) {
+	svc := newBarrierService(func() barrierCacheReader { return &staticBarrierCache{keys: map[string]int64{}} })
+	keys := make([]*schemav1.SchemaKey, barrierMaxKeys+1)
+	for i := range keys {
+		keys[i] = schemaKey("stream", "g", "s")
+	}
+	_, rpcErr := svc.AwaitSchemaDeleted(context.Background(), &schemav1.AwaitSchemaDeletedRequest{
+		Keys:    keys,
+		Timeout: durationpb.New(50 * time.Millisecond),
+	})
+	require.Error(t, rpcErr)
+	st, _ := status.FromError(rpcErr)
+	assert.Equal(t, codes.InvalidArgument, st.Code())
+}
+
+// TestBarrier_AwaitSchemaDeleted_KeyRemovedInTime_ReturnsTrue verifies that when
+// the key is removed from the cache within the timeout, Applied=true is returned.
+func TestBarrier_AwaitSchemaDeleted_KeyRemovedInTime_ReturnsTrue(t *testing.T) {
+	cache := newAdvancingCache(0, map[string]int64{"stream_g/s": 10})
+	svc := newBarrierService(func() barrierCacheReader { return cache })
+	go func() {
+		time.Sleep(20 * time.Millisecond)
+		cache.removeKey("stream_g/s")
+	}()
+	resp, rpcErr := svc.AwaitSchemaDeleted(context.Background(), &schemav1.AwaitSchemaDeletedRequest{
+		Keys:    []*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+		Timeout: durationpb.New(500 * time.Millisecond),
+	})
+	require.NoError(t, rpcErr)
+	assert.True(t, resp.GetApplied())
+}
+
+// Pure function tests follow.
+
+// TestBarrierBackoff_GrowsByFactor verifies the backoff grows by barrierGrowthFactor each step.
+func TestBarrierBackoff_GrowsByFactor(t *testing.T) {
+	d := barrierInitInterval // 10ms
+	next := barrierBackoff(d)
+	expected := time.Duration(float64(d) * barrierGrowthFactor)
+	assert.Equal(t, expected, next)
+}
+
+// TestBarrierBackoff_CapsAtMaxInterval verifies the backoff never exceeds barrierMaxInterval.
+func TestBarrierBackoff_CapsAtMaxInterval(t *testing.T) {
+	// Feed a very large duration and verify it is capped.
+	result := barrierBackoff(10 * time.Second)
+	assert.Equal(t, barrierMaxInterval, result)
+}
+
+// TestBarrierDeadlineDuration_NilInput_ReturnsDefault verifies that a nil timeout proto
+// returns barrierDefaultTimeout — large enough to cover normal schema propagation rather
+// than the poll-interval cap.
+func TestBarrierDeadlineDuration_NilInput_ReturnsDefault(t *testing.T) {
+	assert.Equal(t, barrierDefaultTimeout, barrierDeadlineDuration(nil))
+}
+
+// TestBarrierDeadlineDuration_ZeroInput_ReturnsDefault verifies that a zero duration
+// proto returns barrierDefaultTimeout for the same reason as the nil case.
+func TestBarrierDeadlineDuration_ZeroInput_ReturnsDefault(t *testing.T) {
+	assert.Equal(t, barrierDefaultTimeout, barrierDeadlineDuration(durationpb.New(0)))
+}
+
+// TestBarrierDeadlineDuration_PositiveInput_ReturnsExact verifies that a positive
+// timeout is passed through unchanged.
+func TestBarrierDeadlineDuration_PositiveInput_ReturnsExact(t *testing.T) {
+	want := 2 * time.Second
+	assert.Equal(t, want, barrierDeadlineDuration(durationpb.New(want)))
+}
+
+// TestSchemaKeyToPropID_Stream verifies that a stream key is encoded as
+// "stream_<group>/<name>".
+func TestSchemaKeyToPropID_Stream(t *testing.T) {
+	key := schemaKey("stream", "my-group", "my-stream")
+	assert.Equal(t, "stream_my-group/my-stream", schemaKeyToPropID(key))
+}
+
+// TestSchemaKeyToPropID_Measure verifies that a measure key is encoded as
+// "measure_<group>/<name>".
+func TestSchemaKeyToPropID_Measure(t *testing.T) {
+	key := schemaKey("measure", "grp", "m1")
+	assert.Equal(t, "measure_grp/m1", schemaKeyToPropID(key))
+}
+
+// TestSchemaKeyToPropID_Group verifies that a group key (no group field) is
+// encoded as "group_<name>".
+func TestSchemaKeyToPropID_Group(t *testing.T) {
+	key := schemaKey("group", "", "g1")
+	assert.Equal(t, "group_g1", schemaKeyToPropID(key))
+}
+
+// TestProtoKindToString_CompoundKinds verifies that underscore_case proto kind strings
+// are normalised to their schema.Kind.String() equivalents.
+func TestProtoKindToString_CompoundKinds(t *testing.T) {
+	cases := []struct {
+		proto string
+		want  string
+	}{
+		{"index_rule", "indexRule"},
+		{"index_rule_binding", "indexRuleBinding"},
+		{"top_n_aggregation", "topNAggregation"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.proto, func(t *testing.T) {
+			assert.Equal(t, tc.want, protoKindToString(tc.proto))
+		})
+	}
+}
+
+// TestProtoKindToString_SimpleKinds verifies that simple kinds (stream, measure,
+// trace, group, property) pass through unchanged.
+func TestProtoKindToString_SimpleKinds(t *testing.T) {
+	for _, kind := range []string{"stream", "measure", "trace", "group", "property", "node"} {
+		t.Run(kind, func(t *testing.T) {
+			assert.Equal(t, kind, protoKindToString(kind))
+		})
+	}
+}
+
+// TestBarrier_CollectMissingKeys_PartialPresence verifies that only absent or
+// below-minRevision keys appear in the missing list.
+func TestBarrier_CollectMissingKeys_PartialPresence(t *testing.T) {
+	cache := &staticBarrierCache{
+		keys: map[string]int64{
+			"stream_g/s1": 10, // present, above minRev 5
+			// "stream_g/s2" absent
+		},
+	}
+	svc := newBarrierService(func() barrierCacheReader { return cache })
+	keys := []*schemav1.SchemaKey{
+		schemaKey("stream", "g", "s1"),
+		schemaKey("stream", "g", "s2"),
+	}
+	missing := svc.collectMissingKeys(keys, []int64{5, 1})
+	require.Len(t, missing, 1)
+	assert.Equal(t, "s2", missing[0].GetName())
+}
+
+// TestBarrier_CollectMissingKeys_BelowMinRevision verifies that a key present in
+// the cache but below the required minRevision is reported as missing.
+func TestBarrier_CollectMissingKeys_BelowMinRevision(t *testing.T) {
+	cache := &staticBarrierCache{keys: map[string]int64{"stream_g/s": 5}} // present at rev 5, but minRev = 10
+	svc := newBarrierService(func() barrierCacheReader { return cache })
+	missing := svc.collectMissingKeys(
+		[]*schemav1.SchemaKey{schemaKey("stream", "g", "s")},
+		[]int64{10},
+	)
+	require.Len(t, missing, 1, "key at rev 5 with minRev 10 must be reported as missing")
+}
+
+// TestBarrier_CollectPresentKeys_FiltersAbsent verifies that only keys still
+// present in the cache are returned.
+func TestBarrier_CollectPresentKeys_FiltersAbsent(t *testing.T) {
+	cache := &staticBarrierCache{
+		keys: map[string]int64{
+			"stream_g/s1": 10,
+			// "stream_g/s2" absent
+		},
+	}
+	svc := newBarrierService(func() barrierCacheReader { return cache })
+	keys := []*schemav1.SchemaKey{
+		schemaKey("stream", "g", "s1"),
+		schemaKey("stream", "g", "s2"),
+	}
+	present := svc.collectPresentKeys(keys)
+	require.Len(t, present, 1)
+	assert.Equal(t, "s1", present[0].GetName())
+}
diff --git a/banyand/liaison/grpc/clamp_test.go b/banyand/liaison/grpc/clamp_test.go
new file mode 100644
index 000000000..613b4341f
--- /dev/null
+++ b/banyand/liaison/grpc/clamp_test.go
@@ -0,0 +1,140 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// base anchors all test timestamps to a fixed, deterministic instant.
+var base = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+
+// TestClampTimeRangeBegin_EmptyCreatedAts_ReturnsBeginUnchanged verifies that
+// when no createdAts are supplied the begin time is returned unchanged with empty=false.
+func TestClampTimeRangeBegin_EmptyCreatedAts_ReturnsBeginUnchanged(t *testing.T) {
+	begin := base
+	end := base.Add(time.Hour)
+	gotBegin, empty := clampTimeRangeBegin(begin, end, nil)
+	assert.Equal(t, begin, gotBegin)
+	assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_AllZeroCreatedAts_ReturnsBeginUnchanged verifies that
+// zero-value createdAts entries are ignored and begin is returned unchanged.
+func TestClampTimeRangeBegin_AllZeroCreatedAts_ReturnsBeginUnchanged(t *testing.T) {
+	begin := base
+	end := base.Add(time.Hour)
+	gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{{}, {}, {}})
+	assert.Equal(t, begin, gotBegin)
+	assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_BeginBeforeMaxCreatedAt_ClampsToMax verifies that when
+// begin is before the maximum createdAt, begin is advanced to maxCreatedAt.
+func TestClampTimeRangeBegin_BeginBeforeMaxCreatedAt_ClampsToMax(t *testing.T) {
+	createdAt := base.Add(30 * time.Minute)
+	begin := base // before createdAt
+	end := base.Add(2 * time.Hour)
+	gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+	assert.Equal(t, createdAt, gotBegin, "begin must be advanced to maxCreatedAt")
+	assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_BeginAfterMaxCreatedAt_ReturnsBeginUnchanged verifies
+// that when begin is already after the maximum createdAt it is not changed.
+func TestClampTimeRangeBegin_BeginAfterMaxCreatedAt_ReturnsBeginUnchanged(t *testing.T) {
+	createdAt := base
+	begin := base.Add(time.Hour) // already after createdAt
+	end := base.Add(2 * time.Hour)
+	gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+	assert.Equal(t, begin, gotBegin)
+	assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_BeginEqualsMaxCreatedAt_ReturnsBeginUnchanged verifies
+// that begin equal to maxCreatedAt is accepted as-is without being marked empty.
+func TestClampTimeRangeBegin_BeginEqualsMaxCreatedAt_ReturnsBeginUnchanged(t *testing.T) {
+	createdAt := base.Add(30 * time.Minute)
+	begin := createdAt // exactly equal
+	end := base.Add(2 * time.Hour)
+	gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+	assert.Equal(t, begin, gotBegin)
+	assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_ClampedBeginAfterEnd_ReturnsEmpty verifies that when
+// the clamped begin lands after the end, empty=true is returned so the caller
+// can short-circuit and return an empty query response.
+func TestClampTimeRangeBegin_ClampedBeginAfterEnd_ReturnsEmpty(t *testing.T) {
+	// End is far in the past; createdAt is recent → clamped begin > end.
+	createdAt := base.Add(time.Hour)
+	begin := base.Add(-time.Hour)      // before createdAt
+	end := base.Add(-30 * time.Minute) // between begin and createdAt
+	gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+	assert.Equal(t, createdAt, gotBegin)
+	assert.True(t, empty, "clamped begin > end must return empty=true")
+}
+
+// TestClampTimeRangeBegin_ClampedBeginEqualsEnd_NotEmpty verifies the boundary:
+// when the clamped begin is exactly equal to end the range is a single point
+// and empty is false (the caller should proceed with the query).
+func TestClampTimeRangeBegin_ClampedBeginEqualsEnd_NotEmpty(t *testing.T) {
+	createdAt := base.Add(30 * time.Minute)
+	begin := base    // before createdAt
+	end := createdAt // exactly equal to createdAt after clamping
+	gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{createdAt})
+	assert.Equal(t, createdAt, gotBegin)
+	assert.False(t, empty, "clamped begin == end is a valid single-point range, not empty")
+}
+
+// TestClampTimeRangeBegin_ZeroEnd_NeverEmpty verifies that a zero (unbounded) end
+// never causes empty=true regardless of where begin lands after clamping.
+func TestClampTimeRangeBegin_ZeroEnd_NeverEmpty(t *testing.T) {
+	createdAt := base.Add(time.Hour)
+	begin := base
+	gotBegin, empty := clampTimeRangeBegin(begin, time.Time{}, []time.Time{createdAt})
+	assert.Equal(t, createdAt, gotBegin)
+	assert.False(t, empty, "zero end means unbounded — must never return empty=true")
+}
+
+// TestClampTimeRangeBegin_MultipleCreatedAts_UsesMaximum verifies that when
+// multiple createdAts are provided, begin is clamped to the largest one.
+func TestClampTimeRangeBegin_MultipleCreatedAts_UsesMaximum(t *testing.T) {
+	ca1 := base.Add(10 * time.Minute)
+	ca2 := base.Add(45 * time.Minute) // largest
+	ca3 := base.Add(20 * time.Minute)
+	begin := base
+	end := base.Add(2 * time.Hour)
+	gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{ca1, ca2, ca3})
+	assert.Equal(t, ca2, gotBegin, "begin must be clamped to the maximum createdAt across all groups")
+	assert.False(t, empty)
+}
+
+// TestClampTimeRangeBegin_MixedZeroAndNonZeroCreatedAts_IgnoresZeros verifies that
+// zero-value entries among the createdAts are ignored when computing the maximum.
+func TestClampTimeRangeBegin_MixedZeroAndNonZeroCreatedAts_IgnoresZeros(t *testing.T) {
+	ca := base.Add(20 * time.Minute)
+	begin := base
+	end := base.Add(time.Hour)
+	gotBegin, empty := clampTimeRangeBegin(begin, end, []time.Time{{}, ca, {}})
+	assert.Equal(t, ca, gotBegin)
+	assert.False(t, empty)
+}
diff --git a/banyand/liaison/grpc/deletion.go b/banyand/liaison/grpc/deletion.go
index 60edfd033..4670b8ca2 100644
--- a/banyand/liaison/grpc/deletion.go
+++ b/banyand/liaison/grpc/deletion.go
@@ -82,7 +82,7 @@ func (m *groupDeletionTaskManager) initPropertyStorage(ctx context.Context) erro
 		if !errors.Is(getGroupErr, schema.ErrGRPCResourceNotFound) {
 			return fmt.Errorf("failed to get internal deletion task group: %w", getGroupErr)
 		}
-		if createErr := m.schemaRegistry.GroupRegistry().CreateGroup(ctx, group); createErr != nil {
+		if _, createErr := m.schemaRegistry.GroupRegistry().CreateGroup(ctx, group); createErr != nil {
 			return fmt.Errorf("failed to create internal deletion task group: %w", createErr)
 		}
 	}
@@ -241,7 +241,7 @@ func (m *groupDeletionTaskManager) deleteIndexRuleBindings(
 	}
 	task.TotalCounts["index_rule_binding"] = int32(len(bindings))
 	for _, binding := range bindings {
-		if _, deleteErr := m.schemaRegistry.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, binding.GetMetadata()); deleteErr != nil {
+		if _, _, deleteErr := m.schemaRegistry.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, binding.GetMetadata()); deleteErr != nil {
 			return fmt.Errorf("index rule binding %s: %w", binding.GetMetadata().GetName(), deleteErr)
 		}
 	}
@@ -258,7 +258,7 @@ func (m *groupDeletionTaskManager) deleteIndexRules(
 	}
 	task.TotalCounts["index_rule"] = int32(len(indexRules))
 	for _, rule := range indexRules {
-		if _, deleteErr := m.schemaRegistry.IndexRuleRegistry().DeleteIndexRule(ctx, rule.GetMetadata()); deleteErr != nil {
+		if _, _, deleteErr := m.schemaRegistry.IndexRuleRegistry().DeleteIndexRule(ctx, rule.GetMetadata()); deleteErr != nil {
 			return fmt.Errorf("index rule %s: %w", rule.GetMetadata().GetName(), deleteErr)
 		}
 	}
@@ -275,7 +275,7 @@ func (m *groupDeletionTaskManager) deleteProperties(
 	}
 	task.TotalCounts["property"] = int32(len(properties))
 	for _, prop := range properties {
-		if _, deleteErr := m.schemaRegistry.PropertyRegistry().DeleteProperty(ctx, prop.GetMetadata()); deleteErr != nil {
+		if _, _, deleteErr := m.schemaRegistry.PropertyRegistry().DeleteProperty(ctx, prop.GetMetadata()); deleteErr != nil {
 			return fmt.Errorf("property %s: %w", prop.GetMetadata().GetName(), deleteErr)
 		}
 	}
@@ -292,7 +292,7 @@ func (m *groupDeletionTaskManager) deleteStreams(
 	}
 	task.TotalCounts["stream"] = int32(len(streams))
 	for _, stream := range streams {
-		if _, deleteErr := m.schemaRegistry.StreamRegistry().DeleteStream(ctx, stream.GetMetadata()); deleteErr != nil {
+		if _, _, deleteErr := m.schemaRegistry.StreamRegistry().DeleteStream(ctx, stream.GetMetadata()); deleteErr != nil {
 			return fmt.Errorf("stream %s: %w", stream.GetMetadata().GetName(), deleteErr)
 		}
 	}
@@ -309,7 +309,7 @@ func (m *groupDeletionTaskManager) deleteMeasures(
 	}
 	task.TotalCounts["measure"] = int32(len(measures))
 	for _, measure := range measures {
-		if _, deleteErr := m.schemaRegistry.MeasureRegistry().DeleteMeasure(ctx, measure.GetMetadata()); deleteErr != nil {
+		if _, _, deleteErr := m.schemaRegistry.MeasureRegistry().DeleteMeasure(ctx, measure.GetMetadata()); deleteErr != nil {
 			return fmt.Errorf("measure %s: %w", measure.GetMetadata().GetName(), deleteErr)
 		}
 	}
@@ -326,7 +326,7 @@ func (m *groupDeletionTaskManager) deleteTraces(
 	}
 	task.TotalCounts["trace"] = int32(len(traces))
 	for _, trace := range traces {
-		if _, deleteErr := m.schemaRegistry.TraceRegistry().DeleteTrace(ctx, trace.GetMetadata()); deleteErr != nil {
+		if _, _, deleteErr := m.schemaRegistry.TraceRegistry().DeleteTrace(ctx, trace.GetMetadata()); deleteErr != nil {
 			return fmt.Errorf("trace %s: %w", trace.GetMetadata().GetName(), deleteErr)
 		}
 	}
@@ -343,7 +343,7 @@ func (m *groupDeletionTaskManager) deleteTopNAggregations(
 	}
 	task.TotalCounts["topn_aggregation"] = int32(len(topNAggs))
 	for _, agg := range topNAggs {
-		if _, deleteErr := m.schemaRegistry.TopNAggregationRegistry().DeleteTopNAggregation(ctx, agg.GetMetadata()); deleteErr != nil {
+		if _, _, deleteErr := m.schemaRegistry.TopNAggregationRegistry().DeleteTopNAggregation(ctx, agg.GetMetadata()); deleteErr != nil {
 			return fmt.Errorf("topN aggregation %s: %w", agg.GetMetadata().GetName(), deleteErr)
 		}
 	}
@@ -352,7 +352,7 @@ func (m *groupDeletionTaskManager) deleteTopNAggregations(
 }
 
 func (m *groupDeletionTaskManager) deleteGroup(ctx context.Context, group string) error {
-	_, deleteErr := m.schemaRegistry.GroupRegistry().DeleteGroup(ctx, group)
+	_, _, deleteErr := m.schemaRegistry.GroupRegistry().DeleteGroup(ctx, group)
 	if deleteErr != nil && !errors.Is(deleteErr, schema.ErrGRPCResourceNotFound) {
 		return fmt.Errorf("failed to delete group %s: %w", group, deleteErr)
 	}
diff --git a/banyand/liaison/grpc/deletion_test.go b/banyand/liaison/grpc/deletion_test.go
index 88b810a7c..6cf47e957 100644
--- a/banyand/liaison/grpc/deletion_test.go
+++ b/banyand/liaison/grpc/deletion_test.go
@@ -63,16 +63,16 @@ func (s *stubIndexRuleBinding) ListIndexRuleBinding(_ context.Context, _ schema.
 	return nil, nil
 }
 
-func (s *stubIndexRuleBinding) CreateIndexRuleBinding(_ context.Context, _ *databasev1.IndexRuleBinding) error {
-	return nil
+func (s *stubIndexRuleBinding) CreateIndexRuleBinding(_ context.Context, _ *databasev1.IndexRuleBinding) (int64, error) {
+	return 0, nil
 }
 
-func (s *stubIndexRuleBinding) UpdateIndexRuleBinding(_ context.Context, _ *databasev1.IndexRuleBinding) error {
-	return nil
+func (s *stubIndexRuleBinding) UpdateIndexRuleBinding(_ context.Context, _ *databasev1.IndexRuleBinding) (int64, error) {
+	return 0, nil
 }
 
-func (s *stubIndexRuleBinding) DeleteIndexRuleBinding(_ context.Context, _ *commonv1.Metadata) (bool, error) {
-	return true, nil
+func (s *stubIndexRuleBinding) DeleteIndexRuleBinding(_ context.Context, _ *commonv1.Metadata) (bool, int64, error) {
+	return true, 0, nil
 }
 
 // stubIndexRule implements schema.IndexRule returning empty results.
@@ -86,16 +86,16 @@ func (s *stubIndexRule) ListIndexRule(_ context.Context, _ schema.ListOpt) ([]*d
 	return nil, nil
 }
 
-func (s *stubIndexRule) CreateIndexRule(_ context.Context, _ *databasev1.IndexRule) error {
-	return nil
+func (s *stubIndexRule) CreateIndexRule(_ context.Context, _ *databasev1.IndexRule) (int64, error) {
+	return 0, nil
 }
 
-func (s *stubIndexRule) UpdateIndexRule(_ context.Context, _ *databasev1.IndexRule) error {
-	return nil
+func (s *stubIndexRule) UpdateIndexRule(_ context.Context, _ *databasev1.IndexRule) (int64, error) {
+	return 0, nil
 }
 
-func (s *stubIndexRule) DeleteIndexRule(_ context.Context, _ *commonv1.Metadata) (bool, error) {
-	return true, nil
+func (s *stubIndexRule) DeleteIndexRule(_ context.Context, _ *commonv1.Metadata) (bool, int64, error) {
+	return true, 0, nil
 }
 
 func TestHasNonEmptyResources(t *testing.T) {
@@ -223,7 +223,7 @@ func TestDeletion(t *testing.T) {
 		}, nil)
 		mockRepo.EXPECT().DropGroup(gomock.Any(), commonv1.Catalog_CATALOG_STREAM, group).Return(nil)
 		mockGroup.EXPECT().DeleteGroup(gomock.Any(), group).DoAndReturn(
-			func(_ context.Context, g string) (bool, error) {
+			func(_ context.Context, g string) (bool, int64, error) {
 				go func() {
 					time.Sleep(10 * time.Millisecond)
 					gr.OnDelete(schema.Metadata{
@@ -235,7 +235,7 @@ func TestDeletion(t *testing.T) {
 						},
 					})
 				}()
-				return true, nil
+				return true, 0, nil
 			},
 		)
 		mockRepo.EXPECT().GroupRegistry().Return(mockGroup).Times(2)
diff --git a/banyand/liaison/grpc/discovery_test.go b/banyand/liaison/grpc/discovery_test.go
index 291b6786d..9842748de 100644
--- a/banyand/liaison/grpc/discovery_test.go
+++ b/banyand/liaison/grpc/discovery_test.go
@@ -25,9 +25,223 @@ import (
 	"github.com/stretchr/testify/require"
 
 	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
 	"github.com/apache/skywalking-banyandb/pkg/logger"
 )
 
+// entityRepo tombstone GC helpers follow.
+
+// minimalStream returns a Stream proto suitable for entityRepo event tests.
+// Group and name are fixed because every caller uses the same values; introducing
+// variation would only add noise without changing what's being asserted.
+func minimalStream(modRevision int64) *databasev1.Stream {
+	return &databasev1.Stream{
+		Metadata: &commonv1.Metadata{Group: "g", Name: "s", ModRevision: modRevision},
+		Entity:   &databasev1.Entity{TagNames: []string{"svc"}},
+		TagFamilies: []*databasev1.TagFamilySpec{{
+			Name: "default",
+			Tags: []*databasev1.TagSpec{{Name: "svc", Type: databasev1.TagType_TAG_TYPE_STRING}},
+		}},
+	}
+}
+
+// minimalMeasure returns a Measure proto suitable for entityRepo event tests.
+func minimalMeasure(group, name string, modRevision int64) *databasev1.Measure {
+	return &databasev1.Measure{
+		Metadata: &commonv1.Metadata{Group: group, Name: name, ModRevision: modRevision},
+		Entity:   &databasev1.Entity{TagNames: []string{"svc"}},
+		TagFamilies: []*databasev1.TagFamilySpec{{
+			Name: "default",
+			Tags: []*databasev1.TagSpec{{Name: "svc", Type: databasev1.TagType_TAG_TYPE_STRING}},
+		}},
+	}
+}
+
+// minimalTrace returns a Trace proto suitable for entityRepo event tests.
+// tagNames is the ordered list of tag names; traceIDTagName is the one to index.
+func minimalTrace(group, name string, modRevision int64, traceIDTagName string, tagNames ...string) *databasev1.Trace {
+	tags := make([]*databasev1.TraceTagSpec, len(tagNames))
+	for i, n := range tagNames {
+		tags[i] = &databasev1.TraceTagSpec{Name: n}
+	}
+	return &databasev1.Trace{
+		Metadata:       &commonv1.Metadata{Group: group, Name: name, ModRevision: modRevision},
+		TraceIdTagName: traceIDTagName,
+		Tags:           tags,
+	}
+}
+
+func streamAddEvent(s *databasev1.Stream) schema.Metadata {
+	return schema.Metadata{TypeMeta: schema.TypeMeta{Kind: schema.KindStream}, Spec: s}
+}
+
+func measureAddEvent(m *databasev1.Measure) schema.Metadata {
+	return schema.Metadata{TypeMeta: schema.TypeMeta{Kind: schema.KindMeasure}, Spec: m}
+}
+
+func traceAddEvent(tr *databasev1.Trace) schema.Metadata {
+	return schema.Metadata{TypeMeta: schema.TypeMeta{Kind: schema.KindTrace}, Spec: tr}
+}
+
+// entityRepo tombstone GC unit tests follow.
+
+// TestEntityRepo_OnAddOrUpdate_Stream verifies that OnAddOrUpdate for a stream
+// populates entitiesMap with the correct ModRevision and streamMap with the spec.
+func TestEntityRepo_OnAddOrUpdate_Stream(t *testing.T) {
+	er := newEmptyEntityRepo()
+	s := minimalStream(42)
+	er.OnAddOrUpdate(streamAddEvent(s))
+
+	id := identity{group: "g", name: "s"}
+	loc, ok := er.getLocator(id)
+	require.True(t, ok, "entitiesMap must contain the stream locator after OnAddOrUpdate")
+	assert.Equal(t, int64(42), loc.ModRevision)
+
+	gotStream, streamOK := er.getStream(id)
+	require.True(t, streamOK, "streamMap must contain the stream after OnAddOrUpdate")
+	assert.Equal(t, "s", gotStream.GetMetadata().GetName())
+}
+
+// TestEntityRepo_OnAddOrUpdate_Measure verifies that OnAddOrUpdate for a measure
+// populates entitiesMap with the correct ModRevision and measureMap with the spec.
+func TestEntityRepo_OnAddOrUpdate_Measure(t *testing.T) {
+	er := newEmptyEntityRepo()
+	m := minimalMeasure("g", "m", 77)
+	er.OnAddOrUpdate(measureAddEvent(m))
+
+	id := identity{group: "g", name: "m"}
+	loc, ok := er.getLocator(id)
+	require.True(t, ok, "entitiesMap must contain the measure locator after OnAddOrUpdate")
+	assert.Equal(t, int64(77), loc.ModRevision)
+
+	gotMeasure, measureOK := er.getMeasure(id)
+	require.True(t, measureOK, "measureMap must contain the measure after OnAddOrUpdate")
+	assert.Equal(t, "m", gotMeasure.GetMetadata().GetName())
+}
+
+// TestEntityRepo_OnAddOrUpdate_Trace verifies that OnAddOrUpdate for a trace
+// populates traceMap and pre-computes traceIDIndex at the correct position.
+func TestEntityRepo_OnAddOrUpdate_Trace(t *testing.T) {
+	er := newEmptyEntityRepo()
+	// Tags: ["span_id", "trace_id", "svc"] — trace_id is at index 1.
+	tr := minimalTrace("g", "t", 55, "trace_id", "span_id", "trace_id", "svc")
+	er.OnAddOrUpdate(traceAddEvent(tr))
+
+	id := identity{group: "g", name: "t"}
+	gotTrace, traceOK := er.getTrace(id)
+	require.True(t, traceOK, "traceMap must contain the trace after OnAddOrUpdate")
+	assert.Equal(t, "trace_id", gotTrace.GetTraceIdTagName())
+
+	idx, idxOK := er.getTraceIDIndex(id)
+	require.True(t, idxOK)
+	assert.Equal(t, 1, idx, "traceIDIndex must point to the position of trace_id_tag_name in tags")
+}
+
+// TestEntityRepo_OnAddOrUpdate_Trace_MissingTraceID verifies that when
+// trace_id_tag_name does not appear in the tags list the stored index is -1.
+func TestEntityRepo_OnAddOrUpdate_Trace_MissingTraceID(t *testing.T) {
+	er := newEmptyEntityRepo()
+	tr := minimalTrace("g", "t", 10, "absent_tag", "span_id", "svc")
+	er.OnAddOrUpdate(traceAddEvent(tr))
+
+	id := identity{group: "g", name: "t"}
+	idx, _ := er.getTraceIDIndex(id)
+	assert.Equal(t, -1, idx, "traceIDIndex must be -1 when trace_id_tag_name is not found in tags")
+}
+
+// TestEntityRepo_OnDelete_Stream verifies that OnDelete for a stream removes
+// the locator from entitiesMap and the entry from streamMap (tombstone GC).
+func TestEntityRepo_OnDelete_Stream(t *testing.T) {
+	er := newEmptyEntityRepo()
+	s := minimalStream(42)
+	er.OnAddOrUpdate(streamAddEvent(s))
+
+	er.OnDelete(streamAddEvent(s))
+
+	id := identity{group: "g", name: "s"}
+	_, locOK := er.getLocator(id)
+	assert.False(t, locOK, "entitiesMap must be cleared for stream after OnDelete (tombstone GC)")
+
+	_, streamOK := er.getStream(id)
+	assert.False(t, streamOK, "streamMap must be cleared for stream after OnDelete (tombstone GC)")
+}
+
+// TestEntityRepo_OnDelete_Measure verifies that OnDelete for a measure removes
+// the locator from entitiesMap and the entry from measureMap (tombstone GC).
+func TestEntityRepo_OnDelete_Measure(t *testing.T) {
+	er := newEmptyEntityRepo()
+	m := minimalMeasure("g", "m", 77)
+	er.OnAddOrUpdate(measureAddEvent(m))
+
+	er.OnDelete(measureAddEvent(m))
+
+	id := identity{group: "g", name: "m"}
+	_, locOK := er.getLocator(id)
+	assert.False(t, locOK, "entitiesMap must be cleared for measure after OnDelete (tombstone GC)")
+
+	_, measureOK := er.getMeasure(id)
+	assert.False(t, measureOK, "measureMap must be cleared for measure after OnDelete (tombstone GC)")
+}
+
+// TestEntityRepo_OnDelete_Trace verifies that OnDelete for a trace removes
+// the entry from traceMap and traceIDIndexMap (tombstone GC).
+func TestEntityRepo_OnDelete_Trace(t *testing.T) {
+	er := newEmptyEntityRepo()
+	tr := minimalTrace("g", "t", 55, "trace_id", "trace_id")
+	er.OnAddOrUpdate(traceAddEvent(tr))
+
+	er.OnDelete(traceAddEvent(tr))
+
+	id := identity{group: "g", name: "t"}
+	_, traceOK := er.getTrace(id)
+	assert.False(t, traceOK, "traceMap must be cleared for trace after OnDelete (tombstone GC)")
+
+	idx, _ := er.getTraceIDIndex(id)
+	assert.Equal(t, -1, idx, "traceIDIndexMap must be cleared for trace after OnDelete (tombstone GC)")
+}
+
+// TestEntityRepo_AddDeleteAdd_Sequence verifies the full tombstone GC lifecycle:
+// add → present; delete → absent; add again with new revision → present with new revision.
+func TestEntityRepo_AddDeleteAdd_Sequence(t *testing.T) {
+	er := newEmptyEntityRepo()
+	s := minimalStream(10)
+	id := identity{group: "g", name: "s"}
+
+	er.OnAddOrUpdate(streamAddEvent(s))
+	_, ok := er.getLocator(id)
+	require.True(t, ok, "entity must be present after first OnAddOrUpdate")
+
+	er.OnDelete(streamAddEvent(s))
+	_, ok = er.getLocator(id)
+	require.False(t, ok, "entity must be absent after OnDelete (tombstone)")
+
+	s2 := minimalStream(20)
+	er.OnAddOrUpdate(streamAddEvent(s2))
+	loc, ok := er.getLocator(id)
+	require.True(t, ok, "entity must be present after second OnAddOrUpdate")
+	assert.Equal(t, int64(20), loc.ModRevision, "re-added entity must carry the new ModRevision")
+}
+
+// TestEntityRepo_OnDelete_UnknownKind_IsNoop verifies that OnDelete for an
+// unrecognized kind does not panic and leaves existing entries intact.
+func TestEntityRepo_OnDelete_UnknownKind_IsNoop(t *testing.T) {
+	er := newEmptyEntityRepo()
+	s := minimalStream(1)
+	er.OnAddOrUpdate(streamAddEvent(s))
+
+	assert.NotPanics(t, func() {
+		er.OnDelete(schema.Metadata{
+			TypeMeta: schema.TypeMeta{Kind: schema.KindGroup},
+			Spec:     &commonv1.Group{Metadata: &commonv1.Metadata{Group: "g", Name: "s"}},
+		})
+	}, "OnDelete with unsupported kind must not panic")
+
+	id := identity{group: "g", name: "s"}
+	_, ok := er.getLocator(id)
+	assert.True(t, ok, "unsupported kind OnDelete must not remove the stream locator")
+}
+
 func TestGroupRepo_AcquireAndRelease(t *testing.T) {
 	gr := &groupRepo{
 		log:          logger.GetLogger("test"),
diff --git a/banyand/liaison/grpc/measure.go b/banyand/liaison/grpc/measure.go
index 8076a9c61..6017d8698 100644
--- a/banyand/liaison/grpc/measure.go
+++ b/banyand/liaison/grpc/measure.go
@@ -25,6 +25,7 @@ import (
 	"github.com/pkg/errors"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/types/known/timestamppb"
 
 	"github.com/apache/skywalking-banyandb/api/common"
 	"github.com/apache/skywalking-banyandb/api/data"
@@ -189,17 +190,33 @@ func (ms *measureService) validateWriteRequest(writeRequest *measurev1.WriteRequ
 	}
 
 	if metadata.ModRevision > 0 {
-		measureCache, existed := ms.entityRepo.getLocator(getID(metadata))
+		id := getID(metadata)
+		measureCache, existed := ms.entityRepo.getLocator(id)
 		if !existed {
 			ms.l.Error().Stringer("written", writeRequest).Msg("measure schema not found")
 			ms.sendReply(metadata, modelv1.Status_STATUS_NOT_FOUND, writeRequest.GetMessageId(), measure)
 			return modelv1.Status_STATUS_NOT_FOUND
 		}
-		if metadata.ModRevision != measureCache.ModRevision {
+		clientRev := metadata.ModRevision
+		cacheRev := measureCache.ModRevision
+		if clientRev < cacheRev {
 			ms.l.Error().Stringer("written", writeRequest).Msg("the measure schema is expired")
 			ms.sendReply(metadata, modelv1.Status_STATUS_EXPIRED_SCHEMA, writeRequest.GetMessageId(), measure)
 			return modelv1.Status_STATUS_EXPIRED_SCHEMA
 		}
+		if clientRev > cacheRev {
+			reached := awaitRevisionReached(func() int64 {
+				loc, ok := ms.entityRepo.getLocator(id)
+				if !ok {
+					return 0
+				}
+				return loc.ModRevision
+			}, clientRev, ms.maxWaitDuration)
+			if !reached {
+				ms.sendReply(metadata, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, writeRequest.GetMessageId(), measure)
+				return modelv1.Status_STATUS_SCHEMA_NOT_APPLIED
+			}
+		}
 	}
 
 	return modelv1.Status_STATUS_SUCCEED
@@ -419,6 +436,44 @@ func (ms *measureService) Query(ctx context.Context, req *measurev1.QueryRequest
 			}
 		}()
 	}
+	gatedStatuses, shortCircuit := checkQueryGate(req.Groups, req.Name, req.GroupModRevisions,
+		func(name, group string) (int64, bool) {
+			loc, ok := ms.entityRepo.getLocator(identity{name: name, group: group})
+			return loc.ModRevision, ok
+		}, ms.maxWaitDuration)
+	if shortCircuit {
+		return &measurev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+	}
+	// Gate the clamp on the same opt-in trigger as the query gate (GroupModRevisions
+	// non-empty). Schema-aware clients that pass per-group revisions also accept
+	// pre-creation filtering; legacy clients that omit GroupModRevisions retain the
+	// pre-Step-1.6 behavior where data with timestamps preceding CreatedAt is still
+	// returned. This keeps backward compatibility for fixtures that write historical
+	// data into a freshly-created measure.
+	if req.TimeRange != nil && len(req.GroupModRevisions) > 0 {
+		createdAts := make([]time.Time, 0, len(req.Groups))
+		for _, group := range req.Groups {
+			measureEntity, measureOK := ms.entityRepo.getMeasure(identity{name: req.Name, group: group})
+			if measureOK && measureEntity.GetCreatedAt() != nil {
+				createdAts = append(createdAts, measureEntity.GetCreatedAt().AsTime())
+			}
+		}
+		endTime := time.Time{}
+		if req.TimeRange.GetEnd() != nil {
+			endTime = req.TimeRange.GetEnd().AsTime()
+		}
+		originalBegin := req.TimeRange.GetBegin().AsTime()
+		newBegin, rangeEmpty := clampTimeRangeBegin(originalBegin, endTime, createdAts)
+		if rangeEmpty {
+			if len(gatedStatuses) > 0 {
+				return &measurev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+			}
+			return emptyMeasureQueryResponse, nil
+		}
+		if newBegin != originalBegin {
+			req.TimeRange.Begin = timestamppb.New(newBegin)
+		}
+	}
 	feat, err := ms.broadcaster.Publish(ctx, data.TopicMeasureQuery, bus.NewMessage(bus.MessageID(now.UnixNano()), req))
 	if err != nil {
 		return nil, err
@@ -434,6 +489,9 @@ func (ms *measureService) Query(ctx context.Context, req *measurev1.QueryRequest
 	switch d := data.(type) {
 	case *measurev1.QueryResponse:
 		responseDataPointCount = len(d.DataPoints)
+		if len(gatedStatuses) > 0 {
+			d.GroupStatuses = gatedStatuses
+		}
 		return d, nil
 	case *common.Error:
 		return nil, errors.WithMessage(errQueryMsg, d.Error())
diff --git a/banyand/liaison/grpc/measure_write_test.go b/banyand/liaison/grpc/measure_write_test.go
new file mode 100644
index 000000000..e866e89cf
--- /dev/null
+++ b/banyand/liaison/grpc/measure_write_test.go
@@ -0,0 +1,132 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+func newTestMeasureService(er *entityRepo, maxWait time.Duration) *measureService {
+	return &measureService{
+		discoveryService: &discoveryService{entityRepo: er},
+		maxWaitDuration:  maxWait,
+		l:                logger.GetLogger("test"),
+		metrics:          newBypassMetrics(),
+	}
+}
+
+func validMeasureWriteRequest() *measurev1.WriteRequest {
+	return &measurev1.WriteRequest{
+		DataPoint: &measurev1.DataPointValue{
+			Timestamp: timestamppb.New(time.Now().Truncate(time.Millisecond)),
+		},
+	}
+}
+
+// TestValidateWriteRequest_Measure_ClientLower_ReturnsExpiredSchema verifies that
+// a write with client ModRevision below the cached revision is rejected immediately
+// with STATUS_EXPIRED_SCHEMA.
+func TestValidateWriteRequest_Measure_ClientLower_ReturnsExpiredSchema(t *testing.T) {
+	id := identity{group: "g", name: "m"}
+	er := seededLocatorRepo(id)
+	svc := newTestMeasureService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 50}
+	st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, st)
+	require.Len(t, mock.replies, 1)
+	assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Measure_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout
+// verifies that a write with client ModRevision above the cached revision waits up to
+// maxWaitDuration and then returns STATUS_SCHEMA_NOT_APPLIED when the cache stays stale.
+func TestValidateWriteRequest_Measure_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout(t *testing.T) {
+	id := identity{group: "g", name: "m"}
+	er := seededLocatorRepo(id)
+	svc := newTestMeasureService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 200}
+	st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, st)
+	require.Len(t, mock.replies, 1)
+	assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Measure_ClientHigher_SucceedsWhenCacheCatchesUp verifies
+// that a write with client ModRevision ahead of the cache returns STATUS_SUCCEED
+// once the cache advances to the required revision within maxWaitDuration.
+func TestValidateWriteRequest_Measure_ClientHigher_SucceedsWhenCacheCatchesUp(t *testing.T) {
+	id := identity{group: "g", name: "m"}
+	er := seededLocatorRepo(id)
+	svc := newTestMeasureService(er, 500*time.Millisecond)
+	mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+	advanceLocatorAfter(er, id, 200, 20*time.Millisecond)
+
+	meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 200}
+	st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies, "no error reply should be sent when validation succeeds")
+}
+
+// TestValidateWriteRequest_Measure_Equal_ReturnsSucceed verifies that a write with
+// client ModRevision equal to the cached revision is accepted immediately.
+func TestValidateWriteRequest_Measure_Equal_ReturnsSucceed(t *testing.T) {
+	id := identity{group: "g", name: "m"}
+	er := seededLocatorRepo(id)
+	svc := newTestMeasureService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 100}
+	st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies)
+}
+
+// TestValidateWriteRequest_Measure_ZeroRevision_SkipsCheck verifies that a write
+// with ModRevision == 0 skips the revision gate entirely and returns STATUS_SUCCEED,
+// even when no locator is registered for the schema.
+func TestValidateWriteRequest_Measure_ZeroRevision_SkipsCheck(t *testing.T) {
+	// Empty repo — if the gate ran it would return STATUS_NOT_FOUND.
+	er := newEmptyEntityRepo()
+	svc := newTestMeasureService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[measurev1.WriteRequest, measurev1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "m", ModRevision: 0}
+	st := svc.validateWriteRequest(validMeasureWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies)
+}
diff --git a/banyand/liaison/grpc/query_gate_test.go b/banyand/liaison/grpc/query_gate_test.go
new file mode 100644
index 000000000..5fadb6d7a
--- /dev/null
+++ b/banyand/liaison/grpc/query_gate_test.go
@@ -0,0 +1,197 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// staticLocatorRevision returns a getLocatorRevision function backed by a fixed map.
+// Groups present in revs return (rev, true); absent groups return (0, false).
+func staticLocatorRevision(revs map[string]int64) func(string, string) (int64, bool) {
+	return func(_, group string) (int64, bool) {
+		rev, ok := revs[group]
+		return rev, ok
+	}
+}
+
+// advancingLocatorRevision returns a getLocatorRevision function that starts at
+// initialRev for the given group and atomically advances to targetRev after delay.
+func advancingLocatorRevision(group string, initialRev, targetRev int64, delay time.Duration) func(string, string) (int64, bool) {
+	var mu sync.RWMutex
+	current := initialRev
+	go func() {
+		time.Sleep(delay)
+		mu.Lock()
+		current = targetRev
+		mu.Unlock()
+	}()
+	return func(_, g string) (int64, bool) {
+		if g != group {
+			return 0, false
+		}
+		mu.RLock()
+		defer mu.RUnlock()
+		return current, true
+	}
+}
+
+// TestCheckQueryGate_EmptyMap_SkipsGateEntirely verifies that a nil or empty
+// groupModRevisions map causes checkQueryGate to return (nil, false) without
+// consulting the locator at all.
+func TestCheckQueryGate_EmptyMap_SkipsGateEntirely(t *testing.T) {
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1", "g2"},
+		"s",
+		nil,
+		staticLocatorRevision(map[string]int64{"g1": 100}),
+		50*time.Millisecond,
+	)
+	assert.Nil(t, statuses)
+	assert.False(t, shortCircuit)
+}
+
+// TestCheckQueryGate_ZeroRevision_SkipsGroup verifies that a group present in the
+// map with clientRev == 0 is skipped — it does not appear in the returned statuses.
+func TestCheckQueryGate_ZeroRevision_SkipsGroup(t *testing.T) {
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1"},
+		"s",
+		map[string]int64{"g1": 0},
+		staticLocatorRevision(map[string]int64{"g1": 100}),
+		50*time.Millisecond,
+	)
+	assert.Nil(t, statuses)
+	assert.False(t, shortCircuit)
+}
+
+// TestCheckQueryGate_Equal_ReturnsSucceedAndNoShortCircuit verifies that a client
+// revision equal to the cached revision is accepted immediately with STATUS_SUCCEED
+// and does not trigger short-circuit.
+func TestCheckQueryGate_Equal_ReturnsSucceedAndNoShortCircuit(t *testing.T) {
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1"},
+		"s",
+		map[string]int64{"g1": 100},
+		staticLocatorRevision(map[string]int64{"g1": 100}),
+		50*time.Millisecond,
+	)
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, statuses["g1"])
+	assert.False(t, shortCircuit)
+}
+
+// TestCheckQueryGate_ClientLower_ReturnsExpiredSchemaAndShortCircuits verifies that
+// a client revision below the cached revision is rejected immediately with
+// STATUS_EXPIRED_SCHEMA and the short-circuit flag is set.
+func TestCheckQueryGate_ClientLower_ReturnsExpiredSchemaAndShortCircuits(t *testing.T) {
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1"},
+		"s",
+		map[string]int64{"g1": 50},
+		staticLocatorRevision(map[string]int64{"g1": 100}),
+		50*time.Millisecond,
+	)
+	assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, statuses["g1"])
+	assert.True(t, shortCircuit)
+}
+
+// TestCheckQueryGate_ClientHigher_WaitsAndReturnsNotAppliedOnTimeout verifies that
+// a client revision above the cached revision causes checkQueryGate to wait up to
+// maxWait and then return STATUS_SCHEMA_NOT_APPLIED with shortCircuit=true when the
+// cache never catches up.
+func TestCheckQueryGate_ClientHigher_WaitsAndReturnsNotAppliedOnTimeout(t *testing.T) {
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1"},
+		"s",
+		map[string]int64{"g1": 99999},
+		staticLocatorRevision(map[string]int64{"g1": 100}),
+		50*time.Millisecond,
+	)
+	assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, statuses["g1"])
+	assert.True(t, shortCircuit)
+}
+
+// TestCheckQueryGate_ClientHigher_SucceedsWhenCacheCatchesUp verifies that a client
+// revision above the cache produces STATUS_SUCCEED with shortCircuit=false once the
+// cache advances to the required revision within maxWait.
+func TestCheckQueryGate_ClientHigher_SucceedsWhenCacheCatchesUp(t *testing.T) {
+	getter := advancingLocatorRevision("g1", 100, 200, 20*time.Millisecond)
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1"},
+		"s",
+		map[string]int64{"g1": 200},
+		getter,
+		500*time.Millisecond,
+	)
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, statuses["g1"])
+	assert.False(t, shortCircuit)
+}
+
+// TestCheckQueryGate_GroupNotInLocator_ReturnsNotFoundAndShortCircuits verifies that
+// a group present in the request map but absent from the locator cache yields
+// STATUS_NOT_FOUND and triggers short-circuit.
+func TestCheckQueryGate_GroupNotInLocator_ReturnsNotFoundAndShortCircuits(t *testing.T) {
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1"},
+		"s",
+		map[string]int64{"g1": 100},
+		staticLocatorRevision(map[string]int64{}), // g1 absent from locator cache
+		50*time.Millisecond,
+	)
+	assert.Equal(t, modelv1.Status_STATUS_NOT_FOUND, statuses["g1"])
+	assert.True(t, shortCircuit)
+}
+
+// TestCheckQueryGate_MixedGroups_ShortCircuitsWhenAnyGroupFails verifies that when
+// multiple groups are gated and at least one fails (stale revision), the result map
+// contains per-group statuses for all gated groups and shortCircuit is true.
+func TestCheckQueryGate_MixedGroups_ShortCircuitsWhenAnyGroupFails(t *testing.T) {
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1", "g2"},
+		"s",
+		map[string]int64{"g1": 50, "g2": 100},
+		staticLocatorRevision(map[string]int64{"g1": 100, "g2": 100}),
+		50*time.Millisecond,
+	)
+	assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, statuses["g1"])
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, statuses["g2"])
+	assert.True(t, shortCircuit)
+}
+
+// TestCheckQueryGate_PartialCoverage_OnlyGatesGroupsInRevisionMap verifies that
+// groups listed in the groups slice but absent from groupModRevisions are not gated
+// and do not appear in the returned statuses map.
+func TestCheckQueryGate_PartialCoverage_OnlyGatesGroupsInRevisionMap(t *testing.T) {
+	statuses, shortCircuit := checkQueryGate(
+		[]string{"g1", "g2"},
+		"s",
+		map[string]int64{"g1": 100}, // only g1 gated; g2 absent from map
+		staticLocatorRevision(map[string]int64{"g1": 100, "g2": 200}),
+		50*time.Millisecond,
+	)
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, statuses["g1"])
+	_, g2Present := statuses["g2"]
+	assert.False(t, g2Present, "g2 must not appear in statuses when absent from groupModRevisions")
+	assert.False(t, shortCircuit)
+}
diff --git a/banyand/liaison/grpc/registry.go b/banyand/liaison/grpc/registry.go
index 1e9af0c15..58f365213 100644
--- a/banyand/liaison/grpc/registry.go
+++ b/banyand/liaison/grpc/registry.go
@@ -87,13 +87,14 @@ func (rs *streamRegistryServer) Delete(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "stream", "delete")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "stream", "delete")
 	}()
-	ok, err := rs.schemaRegistry.StreamRegistry().DeleteStream(ctx, req.GetMetadata())
+	ok, deleteTime, err := rs.schemaRegistry.StreamRegistry().DeleteStream(ctx, req.GetMetadata())
 	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "stream", "delete")
 		return nil, err
 	}
 	return &databasev1.StreamRegistryServiceDeleteResponse{
-		Deleted: ok,
+		Deleted:    ok,
+		DeleteTime: deleteTime,
 	}, nil
 }
 
@@ -194,11 +195,12 @@ func (rs *indexRuleBindingRegistryServer) Create(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "indexRuleBinding", "create")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRuleBinding", "create")
 	}()
-	if err := rs.schemaRegistry.IndexRuleBindingRegistry().CreateIndexRuleBinding(ctx, req.GetIndexRuleBinding()); err != nil {
+	modRevision, err := rs.schemaRegistry.IndexRuleBindingRegistry().CreateIndexRuleBinding(ctx, req.GetIndexRuleBinding())
+	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "indexRuleBinding", "create")
 		return nil, err
 	}
-	return &databasev1.IndexRuleBindingRegistryServiceCreateResponse{}, nil
+	return &databasev1.IndexRuleBindingRegistryServiceCreateResponse{ModRevision: modRevision}, nil
 }
 
 func (rs *indexRuleBindingRegistryServer) Update(ctx context.Context,
@@ -212,11 +214,12 @@ func (rs *indexRuleBindingRegistryServer) Update(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "indexRuleBinding", "update")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRuleBinding", "update")
 	}()
-	if err := rs.schemaRegistry.IndexRuleBindingRegistry().UpdateIndexRuleBinding(ctx, req.GetIndexRuleBinding()); err != nil {
+	modRevision, err := rs.schemaRegistry.IndexRuleBindingRegistry().UpdateIndexRuleBinding(ctx, req.GetIndexRuleBinding())
+	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "indexRuleBinding", "update")
 		return nil, err
 	}
-	return &databasev1.IndexRuleBindingRegistryServiceUpdateResponse{}, nil
+	return &databasev1.IndexRuleBindingRegistryServiceUpdateResponse{ModRevision: modRevision}, nil
 }
 
 func (rs *indexRuleBindingRegistryServer) Delete(ctx context.Context,
@@ -230,13 +233,14 @@ func (rs *indexRuleBindingRegistryServer) Delete(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "indexRuleBinding", "delete")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRuleBinding", "delete")
 	}()
-	ok, err := rs.schemaRegistry.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, req.GetMetadata())
+	ok, deleteTime, err := rs.schemaRegistry.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, req.GetMetadata())
 	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "indexRuleBinding", "delete")
 		return nil, err
 	}
 	return &databasev1.IndexRuleBindingRegistryServiceDeleteResponse{
-		Deleted: ok,
+		Deleted:    ok,
+		DeleteTime: deleteTime,
 	}, nil
 }
 
@@ -325,11 +329,12 @@ func (rs *indexRuleRegistryServer) Create(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "indexRule", "create")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRule", "create")
 	}()
-	if err := rs.schemaRegistry.IndexRuleRegistry().CreateIndexRule(ctx, req.GetIndexRule()); err != nil {
+	modRevision, err := rs.schemaRegistry.IndexRuleRegistry().CreateIndexRule(ctx, req.GetIndexRule())
+	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "indexRule", "create")
 		return nil, err
 	}
-	return &databasev1.IndexRuleRegistryServiceCreateResponse{}, nil
+	return &databasev1.IndexRuleRegistryServiceCreateResponse{ModRevision: modRevision}, nil
 }
 
 func (rs *indexRuleRegistryServer) Update(ctx context.Context,
@@ -343,11 +348,12 @@ func (rs *indexRuleRegistryServer) Update(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "indexRule", "update")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRule", "update")
 	}()
-	if err := rs.schemaRegistry.IndexRuleRegistry().UpdateIndexRule(ctx, req.GetIndexRule()); err != nil {
+	modRevision, err := rs.schemaRegistry.IndexRuleRegistry().UpdateIndexRule(ctx, req.GetIndexRule())
+	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "indexRule", "update")
 		return nil, err
 	}
-	return &databasev1.IndexRuleRegistryServiceUpdateResponse{}, nil
+	return &databasev1.IndexRuleRegistryServiceUpdateResponse{ModRevision: modRevision}, nil
 }
 
 func (rs *indexRuleRegistryServer) Delete(ctx context.Context,
@@ -361,13 +367,14 @@ func (rs *indexRuleRegistryServer) Delete(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "indexRule", "delete")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "indexRule", "delete")
 	}()
-	ok, err := rs.schemaRegistry.IndexRuleRegistry().DeleteIndexRule(ctx, req.GetMetadata())
+	ok, deleteTime, err := rs.schemaRegistry.IndexRuleRegistry().DeleteIndexRule(ctx, req.GetMetadata())
 	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "indexRule", "delete")
 		return nil, err
 	}
 	return &databasev1.IndexRuleRegistryServiceDeleteResponse{
-		Deleted: ok,
+		Deleted:    ok,
+		DeleteTime: deleteTime,
 	}, nil
 }
 
@@ -497,13 +504,14 @@ func (rs *measureRegistryServer) Delete(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "measure", "delete")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "measure", "delete")
 	}()
-	ok, err := rs.schemaRegistry.MeasureRegistry().DeleteMeasure(ctx, req.GetMetadata())
+	ok, deleteTime, err := rs.schemaRegistry.MeasureRegistry().DeleteMeasure(ctx, req.GetMetadata())
 	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "measure", "delete")
 		return nil, err
 	}
 	return &databasev1.MeasureRegistryServiceDeleteResponse{
-		Deleted: ok,
+		Deleted:    ok,
+		DeleteTime: deleteTime,
 	}, nil
 }
 
@@ -592,11 +600,14 @@ func (rs *groupRegistryServer) Create(ctx context.Context, req *databasev1.Group
 		rs.metrics.totalRegistryFinished.Inc(1, g, "group", "create")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "group", "create")
 	}()
-	if err := rs.schemaRegistry.GroupRegistry().CreateGroup(ctx, req.GetGroup()); err != nil {
+	modRevision, err := rs.schemaRegistry.GroupRegistry().CreateGroup(ctx, req.GetGroup())
+	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "group", "create")
 		return nil, err
 	}
-	return &databasev1.GroupRegistryServiceCreateResponse{}, nil
+	return &databasev1.GroupRegistryServiceCreateResponse{
+		ModRevision: modRevision,
+	}, nil
 }
 
 func (rs *groupRegistryServer) Update(ctx context.Context, req *databasev1.GroupRegistryServiceUpdateRequest) (
@@ -609,11 +620,14 @@ func (rs *groupRegistryServer) Update(ctx context.Context, req *databasev1.Group
 		rs.metrics.totalRegistryFinished.Inc(1, g, "group", "update")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "group", "update")
 	}()
-	if err := rs.schemaRegistry.GroupRegistry().UpdateGroup(ctx, req.GetGroup()); err != nil {
+	modRevision, err := rs.schemaRegistry.GroupRegistry().UpdateGroup(ctx, req.GetGroup())
+	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "group", "update")
 		return nil, err
 	}
-	return &databasev1.GroupRegistryServiceUpdateResponse{}, nil
+	return &databasev1.GroupRegistryServiceUpdateResponse{
+		ModRevision: modRevision,
+	}, nil
 }
 
 func (rs *groupRegistryServer) Delete(ctx context.Context, req *databasev1.GroupRegistryServiceDeleteRequest) (
@@ -877,11 +891,14 @@ func (ts *topNAggregationRegistryServer) Create(ctx context.Context,
 		ts.metrics.totalRegistryFinished.Inc(1, g, "topn_aggregation", "create")
 		ts.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "topn_aggregation", "create")
 	}()
-	if err := ts.schemaRegistry.TopNAggregationRegistry().CreateTopNAggregation(ctx, req.GetTopNAggregation()); err != nil {
+	modRevision, err := ts.schemaRegistry.TopNAggregationRegistry().CreateTopNAggregation(ctx, req.GetTopNAggregation())
+	if err != nil {
 		ts.metrics.totalRegistryErr.Inc(1, g, "topn_aggregation", "create")
 		return nil, err
 	}
-	return &databasev1.TopNAggregationRegistryServiceCreateResponse{}, nil
+	return &databasev1.TopNAggregationRegistryServiceCreateResponse{
+		ModRevision: modRevision,
+	}, nil
 }
 
 func (ts *topNAggregationRegistryServer) Update(ctx context.Context,
@@ -894,11 +911,14 @@ func (ts *topNAggregationRegistryServer) Update(ctx context.Context,
 		ts.metrics.totalRegistryFinished.Inc(1, g, "topn_aggregation", "update")
 		ts.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "topn_aggregation", "update")
 	}()
-	if err := ts.schemaRegistry.TopNAggregationRegistry().UpdateTopNAggregation(ctx, req.GetTopNAggregation()); err != nil {
+	modRevision, err := ts.schemaRegistry.TopNAggregationRegistry().UpdateTopNAggregation(ctx, req.GetTopNAggregation())
+	if err != nil {
 		ts.metrics.totalRegistryErr.Inc(1, g, "topn_aggregation", "update")
 		return nil, err
 	}
-	return &databasev1.TopNAggregationRegistryServiceUpdateResponse{}, nil
+	return &databasev1.TopNAggregationRegistryServiceUpdateResponse{
+		ModRevision: modRevision,
+	}, nil
 }
 
 func (ts *topNAggregationRegistryServer) Delete(ctx context.Context,
@@ -911,13 +931,14 @@ func (ts *topNAggregationRegistryServer) Delete(ctx context.Context,
 		ts.metrics.totalRegistryFinished.Inc(1, g, "topn_aggregation", "delete")
 		ts.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "topn_aggregation", "delete")
 	}()
-	ok, err := ts.schemaRegistry.TopNAggregationRegistry().DeleteTopNAggregation(ctx, req.GetMetadata())
+	ok, deleteTime, err := ts.schemaRegistry.TopNAggregationRegistry().DeleteTopNAggregation(ctx, req.GetMetadata())
 	if err != nil {
 		ts.metrics.totalRegistryErr.Inc(1, g, "topn_aggregation", "delete")
 		return nil, err
 	}
 	return &databasev1.TopNAggregationRegistryServiceDeleteResponse{
-		Deleted: ok,
+		Deleted:    ok,
+		DeleteTime: deleteTime,
 	}, nil
 }
 
@@ -1036,13 +1057,14 @@ func (ps *propertyRegistryServer) Delete(ctx context.Context, req *databasev1.Pr
 		ps.metrics.totalRegistryFinished.Inc(1, g, "property", "delete")
 		ps.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "property", "delete")
 	}()
-	ok, err := ps.schemaRegistry.PropertyRegistry().DeleteProperty(ctx, req.GetMetadata())
+	ok, deleteTime, err := ps.schemaRegistry.PropertyRegistry().DeleteProperty(ctx, req.GetMetadata())
 	if err != nil {
 		ps.metrics.totalRegistryErr.Inc(1, g, "property", "delete")
 		return nil, err
 	}
 	return &databasev1.PropertyRegistryServiceDeleteResponse{
-		Deleted: ok,
+		Deleted:    ok,
+		DeleteTime: deleteTime,
 	}, nil
 }
 
@@ -1167,13 +1189,14 @@ func (rs *traceRegistryServer) Delete(ctx context.Context,
 		rs.metrics.totalRegistryFinished.Inc(1, g, "trace", "delete")
 		rs.metrics.totalRegistryLatency.Inc(time.Since(start).Seconds(), g, "trace", "delete")
 	}()
-	ok, err := rs.schemaRegistry.TraceRegistry().DeleteTrace(ctx, req.GetMetadata())
+	ok, deleteTime, err := rs.schemaRegistry.TraceRegistry().DeleteTrace(ctx, req.GetMetadata())
 	if err != nil {
 		rs.metrics.totalRegistryErr.Inc(1, g, "trace", "delete")
 		return nil, err
 	}
 	return &databasev1.TraceRegistryServiceDeleteResponse{
-		Deleted: ok,
+		Deleted:    ok,
+		DeleteTime: deleteTime,
 	}, nil
 }
 
diff --git a/banyand/liaison/grpc/schema_revision.go b/banyand/liaison/grpc/schema_revision.go
new file mode 100644
index 000000000..ba902f6a6
--- /dev/null
+++ b/banyand/liaison/grpc/schema_revision.go
@@ -0,0 +1,139 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"time"
+
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// clampTimeRangeBegin advances begin to the maximum value in createdAts when begin
+// falls before it (Rule 7: query time-range clamp to schema.CreatedAt).
+// Returns the (possibly clamped) begin time and empty=true when the clamped begin
+// is already after end, which means no data can exist in the resulting range.
+// A zero end is treated as unbounded — empty is never true in that case.
+// createdAts entries that are zero are ignored.
+func clampTimeRangeBegin(begin, end time.Time, createdAts []time.Time) (time.Time, bool) {
+	var maxCreatedAt time.Time
+	for _, ca := range createdAts {
+		if ca.After(maxCreatedAt) {
+			maxCreatedAt = ca
+		}
+	}
+	if maxCreatedAt.IsZero() {
+		return begin, false
+	}
+	if begin.Before(maxCreatedAt) {
+		begin = maxCreatedAt
+	}
+	if !end.IsZero() && begin.After(end) {
+		return begin, true
+	}
+	return begin, false
+}
+
+// checkQueryGate evaluates the per-group ModRevision gate for a query request.
+// It returns the collected group statuses and a shortCircuit flag.
+// When shortCircuit is true the caller must not execute the query and should
+// return a response populated only with the returned group_statuses map.
+// When shortCircuit is false and the returned map is non-empty, all gated
+// groups passed (STATUS_SUCCEED) and the caller should attach the map to the
+// successful query response.
+// An empty groupModRevisions map skips the gate entirely (backward compat).
+//
+// maxWait is a single overall deadline shared across every "ahead" group rather
+// than a per-group budget, so a multi-group query with N gated groups cannot
+// exceed maxWait wall-clock time.
+func checkQueryGate(
+	groups []string,
+	name string,
+	groupModRevisions map[string]int64,
+	getLocatorRevision func(name, group string) (int64, bool),
+	maxWait time.Duration,
+) (map[string]modelv1.Status, bool) {
+	if len(groupModRevisions) == 0 {
+		return nil, false
+	}
+	deadline := time.Now().Add(maxWait)
+	groupStatuses := make(map[string]modelv1.Status)
+	for _, g := range groups {
+		clientRev, revOK := groupModRevisions[g]
+		if !revOK || clientRev == 0 {
+			continue
+		}
+		cacheRev, found := getLocatorRevision(name, g)
+		if !found {
+			groupStatuses[g] = modelv1.Status_STATUS_NOT_FOUND
+			continue
+		}
+		switch {
+		case clientRev < cacheRev:
+			groupStatuses[g] = modelv1.Status_STATUS_EXPIRED_SCHEMA
+		case clientRev > cacheRev:
+			currentGroup := g
+			reached := awaitRevisionReached(func() int64 {
+				rev, _ := getLocatorRevision(name, currentGroup)
+				return rev
+			}, clientRev, time.Until(deadline))
+			if reached {
+				groupStatuses[g] = modelv1.Status_STATUS_SUCCEED
+			} else {
+				groupStatuses[g] = modelv1.Status_STATUS_SCHEMA_NOT_APPLIED
+			}
+		default:
+			groupStatuses[g] = modelv1.Status_STATUS_SUCCEED
+		}
+	}
+	if len(groupStatuses) == 0 {
+		return nil, false
+	}
+	for _, st := range groupStatuses {
+		if st != modelv1.Status_STATUS_SUCCEED {
+			return groupStatuses, true
+		}
+	}
+	return groupStatuses, false
+}
+
+// awaitRevisionReached polls getRevision until the returned value is >= target or maxWait elapses.
+// Returns true when the target revision was observed, false on timeout.
+// The initial check is performed before any sleep, so a cache that is already at target never
+// sleeps. Backoff starts at 10 ms, is multiplied by 1.5 each iteration, and is capped at 1 s.
+// If maxWait <= 0 the function returns false immediately without polling.
+func awaitRevisionReached(getRevision func() int64, target int64, maxWait time.Duration) bool {
+	if getRevision() >= target {
+		return true
+	}
+	if maxWait <= 0 {
+		return false
+	}
+	retryInterval := 10 * time.Millisecond
+	deadline := time.Now().Add(maxWait)
+	for time.Now().Before(deadline) {
+		time.Sleep(retryInterval)
+		if getRevision() >= target {
+			return true
+		}
+		retryInterval = time.Duration(float64(retryInterval) * 1.5)
+		if retryInterval > time.Second {
+			retryInterval = time.Second
+		}
+	}
+	return false
+}
diff --git a/banyand/liaison/grpc/server.go b/banyand/liaison/grpc/server.go
index a73cdfd7b..e8251df27 100644
--- a/banyand/liaison/grpc/server.go
+++ b/banyand/liaison/grpc/server.go
@@ -43,6 +43,7 @@ import (
 	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
 	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
 	propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+	schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
 	streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
 	tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
 	"github.com/apache/skywalking-banyandb/banyand/internal/storage"
@@ -104,7 +105,8 @@ type server struct {
 	stopCh     chan struct{}
 	*indexRuleRegistryServer
 	*measureRegistryServer
-	streamSVC *streamService
+	streamSVC  *streamService
+	barrierSVC *barrierService
 	*streamRegistryServer
 	measureSVC *measureService
 	bydbQLSVC  *bydbQLService
@@ -179,6 +181,20 @@ func NewServer(_ context.Context, tir1Client, tir2Client, broadcaster queue.Clie
 		propertyServer: propertyService,
 	}
 
+	var barrierSVC *barrierService
+	if svc, svcOk := schemaRegistry.(metadata.Service); svcOk {
+		barrierSVC = newBarrierService(func() barrierCacheReader {
+			inner := svc.SchemaRegistry()
+			if inner == nil {
+				return nil
+			}
+			bc, bcOk := inner.(barrierCacheReader)
+			if !bcOk {
+				return nil
+			}
+			return bc
+		})
+	}
 	s := &server{
 		omr:        omr,
 		streamSVC:  streamSVC,
@@ -186,6 +202,7 @@ func NewServer(_ context.Context, tir1Client, tir2Client, broadcaster queue.Clie
 		traceSVC:   traceSVC,
 		bydbQLSVC:  bydbQLSVC,
 		groupRepo:  gr,
+		barrierSVC: barrierSVC,
 		streamRegistryServer: &streamRegistryServer{
 			schemaRegistry: schemaRegistry,
 		},
@@ -489,6 +506,9 @@ func (s *server) Serve() run.StopNotify {
 	databasev1.RegisterTraceRegistryServiceServer(s.ser, s.traceRegistryServer)
 	databasev1.RegisterClusterStateServiceServer(s.ser, s)
 	databasev1.RegisterNodeQueryServiceServer(s.ser, s)
+	if s.barrierSVC != nil {
+		schemav1.RegisterSchemaBarrierServiceServer(s.ser, s.barrierSVC)
+	}
 	grpc_health_v1.RegisterHealthServer(s.ser, health.NewServer())
 
 	s.stopCh = make(chan struct{})
diff --git a/banyand/liaison/grpc/stream.go b/banyand/liaison/grpc/stream.go
index ba26b3ef4..4c4316915 100644
--- a/banyand/liaison/grpc/stream.go
+++ b/banyand/liaison/grpc/stream.go
@@ -25,6 +25,7 @@ import (
 	"github.com/pkg/errors"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/types/known/timestamppb"
 
 	"github.com/apache/skywalking-banyandb/api/common"
 	"github.com/apache/skywalking-banyandb/api/data"
@@ -96,17 +97,33 @@ func (s *streamService) validateWriteRequest(writeEntity *streamv1.WriteRequest,
 	}
 
 	if metadata.ModRevision > 0 {
-		streamCache, existed := s.entityRepo.getLocator(getID(metadata))
+		id := getID(metadata)
+		streamCache, existed := s.entityRepo.getLocator(id)
 		if !existed {
 			s.l.Error().Stringer("written", writeEntity).Msg("stream schema not found")
 			s.sendReply(metadata, modelv1.Status_STATUS_NOT_FOUND, writeEntity.GetMessageId(), stream)
 			return modelv1.Status_STATUS_NOT_FOUND
 		}
-		if metadata.ModRevision != streamCache.ModRevision {
+		clientRev := metadata.ModRevision
+		cacheRev := streamCache.ModRevision
+		if clientRev < cacheRev {
 			s.l.Error().Stringer("written", writeEntity).Msg("the stream schema is expired")
 			s.sendReply(metadata, modelv1.Status_STATUS_EXPIRED_SCHEMA, writeEntity.GetMessageId(), stream)
 			return modelv1.Status_STATUS_EXPIRED_SCHEMA
 		}
+		if clientRev > cacheRev {
+			reached := awaitRevisionReached(func() int64 {
+				loc, ok := s.entityRepo.getLocator(id)
+				if !ok {
+					return 0
+				}
+				return loc.ModRevision
+			}, clientRev, s.maxWaitDuration)
+			if !reached {
+				s.sendReply(metadata, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, writeEntity.GetMessageId(), stream)
+				return modelv1.Status_STATUS_SCHEMA_NOT_APPLIED
+			}
+		}
 	}
 
 	return modelv1.Status_STATUS_SUCCEED
@@ -386,6 +403,40 @@ func (s *streamService) Query(ctx context.Context, req *streamv1.QueryRequest) (
 			}
 		}()
 	}
+	gatedStatuses, shortCircuit := checkQueryGate(req.Groups, req.Name, req.GroupModRevisions,
+		func(name, group string) (int64, bool) {
+			loc, ok := s.entityRepo.getLocator(identity{name: name, group: group})
+			return loc.ModRevision, ok
+		}, s.maxWaitDuration)
+	if shortCircuit {
+		return &streamv1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+	}
+	// See measure.go for rationale: gate the clamp on the same opt-in trigger as the
+	// query gate so legacy clients that omit GroupModRevisions are not affected.
+	if req.TimeRange != nil && len(req.GroupModRevisions) > 0 {
+		createdAts := make([]time.Time, 0, len(req.Groups))
+		for _, group := range req.Groups {
+			streamEntity, streamOK := s.entityRepo.getStream(identity{name: req.Name, group: group})
+			if streamOK && streamEntity.GetCreatedAt() != nil {
+				createdAts = append(createdAts, streamEntity.GetCreatedAt().AsTime())
+			}
+		}
+		endTime := time.Time{}
+		if req.TimeRange.GetEnd() != nil {
+			endTime = req.TimeRange.GetEnd().AsTime()
+		}
+		originalBegin := req.TimeRange.GetBegin().AsTime()
+		newBegin, rangeEmpty := clampTimeRangeBegin(originalBegin, endTime, createdAts)
+		if rangeEmpty {
+			if len(gatedStatuses) > 0 {
+				return &streamv1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+			}
+			return emptyStreamQueryResponse, nil
+		}
+		if newBegin != originalBegin {
+			req.TimeRange.Begin = timestamppb.New(newBegin)
+		}
+	}
 	message := bus.NewMessage(bus.MessageID(now.UnixNano()), req)
 	feat, errQuery := s.broadcaster.Publish(ctx, data.TopicStreamQuery, message)
 	if errQuery != nil {
@@ -402,6 +453,9 @@ func (s *streamService) Query(ctx context.Context, req *streamv1.QueryRequest) (
 	switch d := data.(type) {
 	case *streamv1.QueryResponse:
 		responseElementCount = len(d.Elements)
+		if len(gatedStatuses) > 0 {
+			d.GroupStatuses = gatedStatuses
+		}
 		return d, nil
 	case *common.Error:
 		return nil, errors.WithMessage(errQueryMsg, d.Error())
diff --git a/banyand/liaison/grpc/stream_write_test.go b/banyand/liaison/grpc/stream_write_test.go
new file mode 100644
index 000000000..31af53b73
--- /dev/null
+++ b/banyand/liaison/grpc/stream_write_test.go
@@ -0,0 +1,132 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+	"github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+func newTestStreamService(er *entityRepo, maxWait time.Duration) *streamService {
+	return &streamService{
+		discoveryService: &discoveryService{entityRepo: er},
+		maxWaitDuration:  maxWait,
+		l:                logger.GetLogger("test"),
+		metrics:          newBypassMetrics(),
+	}
+}
+
+func validStreamWriteRequest() *streamv1.WriteRequest {
+	return &streamv1.WriteRequest{
+		Element: &streamv1.ElementValue{
+			Timestamp: timestamppb.New(time.Now().Truncate(time.Millisecond)),
+		},
+	}
+}
+
+// TestValidateWriteRequest_Stream_ClientLower_ReturnsExpiredSchema verifies that
+// a write with client ModRevision below the cached revision is rejected immediately
+// with STATUS_EXPIRED_SCHEMA.
+func TestValidateWriteRequest_Stream_ClientLower_ReturnsExpiredSchema(t *testing.T) {
+	id := identity{group: "g", name: "s"}
+	er := seededLocatorRepo(id)
+	svc := newTestStreamService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 50}
+	st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, st)
+	require.Len(t, mock.replies, 1)
+	assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Stream_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout
+// verifies that a write with client ModRevision above the cached revision waits up to
+// maxWaitDuration and then returns STATUS_SCHEMA_NOT_APPLIED when the cache stays stale.
+func TestValidateWriteRequest_Stream_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout(t *testing.T) {
+	id := identity{group: "g", name: "s"}
+	er := seededLocatorRepo(id)
+	svc := newTestStreamService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 200}
+	st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, st)
+	require.Len(t, mock.replies, 1)
+	assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Stream_ClientHigher_SucceedsWhenCacheCatchesUp verifies
+// that a write with client ModRevision ahead of the cache returns STATUS_SUCCEED
+// once the cache advances to the required revision within maxWaitDuration.
+func TestValidateWriteRequest_Stream_ClientHigher_SucceedsWhenCacheCatchesUp(t *testing.T) {
+	id := identity{group: "g", name: "s"}
+	er := seededLocatorRepo(id)
+	svc := newTestStreamService(er, 500*time.Millisecond)
+	mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+	advanceLocatorAfter(er, id, 200, 20*time.Millisecond)
+
+	meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 200}
+	st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies, "no error reply should be sent when validation succeeds")
+}
+
+// TestValidateWriteRequest_Stream_Equal_ReturnsSucceed verifies that a write with
+// client ModRevision equal to the cached revision is accepted immediately.
+func TestValidateWriteRequest_Stream_Equal_ReturnsSucceed(t *testing.T) {
+	id := identity{group: "g", name: "s"}
+	er := seededLocatorRepo(id)
+	svc := newTestStreamService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 100}
+	st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies)
+}
+
+// TestValidateWriteRequest_Stream_ZeroRevision_SkipsCheck verifies that a write
+// with ModRevision == 0 skips the revision gate entirely and returns STATUS_SUCCEED,
+// even when no locator is registered for the schema.
+func TestValidateWriteRequest_Stream_ZeroRevision_SkipsCheck(t *testing.T) {
+	// Empty repo — if the gate ran it would return STATUS_NOT_FOUND.
+	er := newEmptyEntityRepo()
+	svc := newTestStreamService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[streamv1.WriteRequest, streamv1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "s", ModRevision: 0}
+	st := svc.validateWriteRequest(validStreamWriteRequest(), meta, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies)
+}
diff --git a/banyand/liaison/grpc/trace.go b/banyand/liaison/grpc/trace.go
index 3143d6418..5d3164590 100644
--- a/banyand/liaison/grpc/trace.go
+++ b/banyand/liaison/grpc/trace.go
@@ -25,6 +25,7 @@ import (
 	"github.com/pkg/errors"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/types/known/timestamppb"
 
 	"github.com/apache/skywalking-banyandb/api/common"
 	"github.com/apache/skywalking-banyandb/api/data"
@@ -144,11 +145,26 @@ func (s *traceService) validateWriteRequest(writeEntity *tracev1.WriteRequest,
 	}
 
 	if metadata.ModRevision > 0 {
-		if metadata.ModRevision != traceEntity.GetMetadata().GetModRevision() {
+		clientRev := metadata.ModRevision
+		cacheRev := traceEntity.GetMetadata().GetModRevision()
+		if clientRev < cacheRev {
 			s.l.Error().Stringer("written", writeEntity).Msg("the trace schema is expired")
 			s.sendReply(metadata, modelv1.Status_STATUS_EXPIRED_SCHEMA, writeEntity.GetVersion(), stream)
 			return modelv1.Status_STATUS_EXPIRED_SCHEMA
 		}
+		if clientRev > cacheRev {
+			reached := awaitRevisionReached(func() int64 {
+				e, ok := s.entityRepo.getTrace(id)
+				if !ok {
+					return 0
+				}
+				return e.GetMetadata().GetModRevision()
+			}, clientRev, s.maxWaitDuration)
+			if !reached {
+				s.sendReply(metadata, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, writeEntity.GetVersion(), stream)
+				return modelv1.Status_STATUS_SCHEMA_NOT_APPLIED
+			}
+		}
 	}
 
 	return modelv1.Status_STATUS_SUCCEED
@@ -417,6 +433,36 @@ func (s *traceService) Write(stream tracev1.TraceService_WriteServer) error {
 
 var emptyTraceQueryResponse = &tracev1.QueryResponse{Traces: make([]*tracev1.Trace, 0)}
 
+// applyClamp rewrites req.TimeRange.Begin to schema.CreatedAt for schema-aware
+// queries (those carrying GroupModRevisions). Returns true when the clamped
+// range is empty, in which case the caller should short-circuit with an empty
+// response. See measure.go for the rationale on the GroupModRevisions opt-in.
+func (s *traceService) applyClamp(req *tracev1.QueryRequest) bool {
+	if req.TimeRange == nil || len(req.GroupModRevisions) == 0 {
+		return false
+	}
+	createdAts := make([]time.Time, 0, len(req.Groups))
+	for _, group := range req.Groups {
+		traceEntity, traceOK := s.entityRepo.getTrace(identity{name: req.Name, group: group})
+		if traceOK && traceEntity.GetCreatedAt() != nil {
+			createdAts = append(createdAts, traceEntity.GetCreatedAt().AsTime())
+		}
+	}
+	endTime := time.Time{}
+	if req.TimeRange.GetEnd() != nil {
+		endTime = req.TimeRange.GetEnd().AsTime()
+	}
+	originalBegin := req.TimeRange.GetBegin().AsTime()
+	newBegin, rangeEmpty := clampTimeRangeBegin(originalBegin, endTime, createdAts)
+	if rangeEmpty {
+		return true
+	}
+	if newBegin != originalBegin {
+		req.TimeRange.Begin = timestamppb.New(newBegin)
+	}
+	return false
+}
+
 func (s *traceService) Query(ctx context.Context, req *tracev1.QueryRequest) (resp *tracev1.QueryResponse, err error) {
 	for _, g := range req.Groups {
 		if acquireErr := s.groupRepo.acquireRequest(g); acquireErr != nil {
@@ -475,6 +521,20 @@ func (s *traceService) Query(ctx context.Context, req *tracev1.QueryRequest) (re
 			}
 		}()
 	}
+	gatedStatuses, shortCircuit := checkQueryGate(req.Groups, req.Name, req.GroupModRevisions,
+		func(name, group string) (int64, bool) {
+			loc, ok := s.entityRepo.getLocator(identity{name: name, group: group})
+			return loc.ModRevision, ok
+		}, s.maxWaitDuration)
+	if shortCircuit {
+		return &tracev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+	}
+	if rangeEmpty := s.applyClamp(req); rangeEmpty {
+		if len(gatedStatuses) > 0 {
+			return &tracev1.QueryResponse{GroupStatuses: gatedStatuses}, nil
+		}
+		return emptyTraceQueryResponse, nil
+	}
 	message := bus.NewMessage(bus.MessageID(now.UnixNano()), req)
 	var future bus.Future
 	future, err = s.broadcaster.Publish(ctx, data.TopicTraceQuery, message)
@@ -500,10 +560,14 @@ func (s *traceService) Query(ctx context.Context, req *tracev1.QueryRequest) (re
 			traces = append(traces, trace)
 		}
 		responseTraceCount = len(traces)
-		return &tracev1.QueryResponse{
+		queryResp := &tracev1.QueryResponse{
 			Traces:           traces,
 			TraceQueryResult: d.TraceQueryResult,
-		}, nil
+		}
+		if len(gatedStatuses) > 0 {
+			queryResp.GroupStatuses = gatedStatuses
+		}
+		return queryResp, nil
 	case *common.Error:
 		return nil, errors.WithMessage(errQueryMsg, d.Error())
 	}
diff --git a/banyand/liaison/grpc/trace_write_test.go b/banyand/liaison/grpc/trace_write_test.go
new file mode 100644
index 000000000..989ea298c
--- /dev/null
+++ b/banyand/liaison/grpc/trace_write_test.go
@@ -0,0 +1,138 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
+	"github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+func newTestTraceService(er *entityRepo, maxWait time.Duration) *traceService {
+	return &traceService{
+		discoveryService: &discoveryService{entityRepo: er},
+		maxWaitDuration:  maxWait,
+		l:                logger.GetLogger("test"),
+		metrics:          newBypassMetrics(),
+	}
+}
+
+// validTraceWriteRequest returns a WriteRequest whose tags include one timestamp tag,
+// satisfying the foundTimestamp check in validateWriteRequest.
+func validTraceWriteRequest() *tracev1.WriteRequest {
+	return &tracev1.WriteRequest{
+		Tags: []*modelv1.TagValue{{
+			Value: &modelv1.TagValue_Timestamp{
+				Timestamp: timestamppb.New(time.Now().Truncate(time.Millisecond)),
+			},
+		}},
+	}
+}
+
+// TestValidateWriteRequest_Trace_ClientLower_ReturnsExpiredSchema verifies that
+// a write with client ModRevision below the cached revision is rejected immediately
+// with STATUS_EXPIRED_SCHEMA.
+func TestValidateWriteRequest_Trace_ClientLower_ReturnsExpiredSchema(t *testing.T) {
+	id := identity{group: "g", name: "t"}
+	er := seededTraceRepo(id, 100)
+	svc := newTestTraceService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 50}
+	st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA, st)
+	require.Len(t, mock.replies, 1)
+	assert.Equal(t, modelv1.Status_STATUS_EXPIRED_SCHEMA.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Trace_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout
+// verifies that a write with client ModRevision above the cached revision waits up to
+// maxWaitDuration and then returns STATUS_SCHEMA_NOT_APPLIED when the cache stays stale.
+func TestValidateWriteRequest_Trace_ClientHigher_WaitsAndReturnsNotApplied_OnTimeout(t *testing.T) {
+	id := identity{group: "g", name: "t"}
+	er := seededTraceRepo(id, 100)
+	svc := newTestTraceService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 200}
+	st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED, st)
+	require.Len(t, mock.replies, 1)
+	assert.Equal(t, modelv1.Status_STATUS_SCHEMA_NOT_APPLIED.String(), mock.replies[0].Status)
+}
+
+// TestValidateWriteRequest_Trace_ClientHigher_SucceedsWhenCacheCatchesUp verifies
+// that a write with client ModRevision ahead of the trace cache returns STATUS_SUCCEED
+// once the trace entry advances to the required revision within maxWaitDuration.
+func TestValidateWriteRequest_Trace_ClientHigher_SucceedsWhenCacheCatchesUp(t *testing.T) {
+	id := identity{group: "g", name: "t"}
+	er := seededTraceRepo(id, 100)
+	svc := newTestTraceService(er, 500*time.Millisecond)
+	mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+	advanceTraceRevAfter(er, id, 200, 20*time.Millisecond)
+
+	meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 200}
+	st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies, "no error reply should be sent when validation succeeds")
+}
+
+// TestValidateWriteRequest_Trace_Equal_ReturnsSucceed verifies that a write with
+// client ModRevision equal to the cached trace revision is accepted immediately.
+func TestValidateWriteRequest_Trace_Equal_ReturnsSucceed(t *testing.T) {
+	id := identity{group: "g", name: "t"}
+	er := seededTraceRepo(id, 100)
+	svc := newTestTraceService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 100}
+	st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies)
+}
+
+// TestValidateWriteRequest_Trace_ZeroRevision_SkipsCheck verifies that a write
+// with ModRevision == 0 skips the revision gate and returns STATUS_SUCCEED.
+// The trace entity must still be present because the trace path always resolves the
+// entity before the revision check.
+func TestValidateWriteRequest_Trace_ZeroRevision_SkipsCheck(t *testing.T) {
+	id := identity{group: "g", name: "t"}
+	// Seed the trace entity so getTrace(id) finds it; the revision value does not matter.
+	er := seededTraceRepo(id, 999)
+	svc := newTestTraceService(er, 50*time.Millisecond)
+	mock := &mockBidiServer[tracev1.WriteRequest, tracev1.WriteResponse]{}
+
+	meta := &commonv1.Metadata{Group: "g", Name: "t", ModRevision: 0}
+	st := svc.validateWriteRequest(validTraceWriteRequest(), meta, nil, mock)
+
+	assert.Equal(t, modelv1.Status_STATUS_SUCCEED, st)
+	assert.Empty(t, mock.replies)
+}
diff --git a/banyand/liaison/grpc/write_helpers_test.go b/banyand/liaison/grpc/write_helpers_test.go
new file mode 100644
index 000000000..bc789a507
--- /dev/null
+++ b/banyand/liaison/grpc/write_helpers_test.go
@@ -0,0 +1,118 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package grpc
+
+import (
+	"context"
+	"io"
+	"sync"
+	"time"
+
+	grpcmd "google.golang.org/grpc/metadata"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	"github.com/apache/skywalking-banyandb/banyand/observability"
+	"github.com/apache/skywalking-banyandb/pkg/logger"
+	"github.com/apache/skywalking-banyandb/pkg/partition"
+)
+
+// mockBidiServer captures Send calls emitted by service handlers under test.
+// It satisfies grpc.BidiStreamingServer[Req, Res] (= grpc.ServerStream + Recv + Send).
+type mockBidiServer[Req any, Res any] struct {
+	replies []*Res
+	mu      sync.Mutex
+}
+
+func (s *mockBidiServer[Req, Res]) Send(resp *Res) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.replies = append(s.replies, resp)
+	return nil
+}
+
+func (s *mockBidiServer[Req, Res]) Recv() (*Req, error)        { return nil, io.EOF }
+func (s *mockBidiServer[Req, Res]) Context() context.Context   { return context.Background() }
+func (s *mockBidiServer[Req, Res]) SetHeader(grpcmd.MD) error  { return nil }
+func (s *mockBidiServer[Req, Res]) SendHeader(grpcmd.MD) error { return nil }
+func (s *mockBidiServer[Req, Res]) SetTrailer(grpcmd.MD)       {}
+func (s *mockBidiServer[Req, Res]) SendMsg(any) error          { return nil }
+func (s *mockBidiServer[Req, Res]) RecvMsg(any) error          { return nil }
+
+// newBypassMetrics returns a metrics instance backed by a no-op registry.
+func newBypassMetrics() *metrics {
+	factory := observability.NewBypassRegistry().With(liaisonGrpcScope)
+	return newMetrics(factory)
+}
+
+// newEmptyEntityRepo constructs an entityRepo with all maps initialized but empty.
+func newEmptyEntityRepo() *entityRepo {
+	return &entityRepo{
+		log:             logger.GetLogger("test"),
+		entitiesMap:     make(map[identity]partition.Locator),
+		measureMap:      make(map[identity]*databasev1.Measure),
+		streamMap:       make(map[identity]*databasev1.Stream),
+		traceMap:        make(map[identity]*databasev1.Trace),
+		traceIDIndexMap: make(map[identity]int),
+	}
+}
+
+// seededLocatorRepoRev is the baseline ModRevision every test uses for the seeded
+// locator. Picking 100 leaves headroom for tests that simulate stale (<100) and
+// future (>100) client revisions against this baseline.
+const seededLocatorRepoRev int64 = 100
+
+// seededLocatorRepo returns an entityRepo with a single locator entry at the
+// fixed seededLocatorRepoRev baseline. Tests that need a different revision
+// should mutate er.entitiesMap directly.
+func seededLocatorRepo(id identity) *entityRepo {
+	er := newEmptyEntityRepo()
+	er.entitiesMap[id] = partition.Locator{ModRevision: seededLocatorRepoRev}
+	return er
+}
+
+// seededTraceRepo returns an entityRepo with a single trace entry at rev.
+func seededTraceRepo(id identity, rev int64) *entityRepo {
+	er := newEmptyEntityRepo()
+	er.traceMap[id] = &databasev1.Trace{
+		Metadata: &commonv1.Metadata{Group: id.group, Name: id.name, ModRevision: rev},
+	}
+	return er
+}
+
+// advanceLocatorAfter advances the locator revision for id in er to newRev after delay.
+func advanceLocatorAfter(er *entityRepo, id identity, newRev int64, delay time.Duration) {
+	go func() {
+		time.Sleep(delay)
+		er.RWMutex.Lock()
+		er.entitiesMap[id] = partition.Locator{ModRevision: newRev}
+		er.RWMutex.Unlock()
+	}()
+}
+
+// advanceTraceRevAfter replaces the trace entry for id with one at newRev after delay.
+func advanceTraceRevAfter(er *entityRepo, id identity, newRev int64, delay time.Duration) {
+	go func() {
+		time.Sleep(delay)
+		er.RWMutex.Lock()
+		er.traceMap[id] = &databasev1.Trace{
+			Metadata: &commonv1.Metadata{Group: id.group, Name: id.name, ModRevision: newRev},
+		}
+		er.RWMutex.Unlock()
+	}()
+}
diff --git a/banyand/measure/metadata.go b/banyand/measure/metadata.go
index f14b87416..29ff21ebc 100644
--- a/banyand/measure/metadata.go
+++ b/banyand/measure/metadata.go
@@ -301,9 +301,10 @@ func (sr *schemaRepo) OnDelete(metadata schema.Metadata) {
 	case schema.KindMeasure:
 		m := metadata.Spec.(*databasev1.Measure)
 		sr.SendMetadataEvent(resourceSchema.MetadataEvent{
-			Typ:      resourceSchema.EventDelete,
-			Kind:     resourceSchema.EventKindResource,
-			Metadata: m,
+			Typ:            resourceSchema.EventDelete,
+			Kind:           resourceSchema.EventKindResource,
+			DeleteRevision: m.GetMetadata().GetModRevision(),
+			Metadata:       m,
 		})
 		sr.stopSteamingManager(m.GetMetadata())
 	case schema.KindIndexRuleBinding:
diff --git a/banyand/measure/metadata_test.go b/banyand/measure/metadata_test.go
index 0c366bfa1..ecb647581 100644
--- a/banyand/measure/metadata_test.go
+++ b/banyand/measure/metadata_test.go
@@ -60,7 +60,7 @@ var _ = Describe("Metadata", func() {
 
 	Context("Manage group", func() {
 		It("should close the group", func() {
-			deleted, err := svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), "sw_metric")
+			deleted, _, err := svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), "sw_metric")
 			Expect(err).ShouldNot(HaveOccurred())
 			Expect(deleted).Should(BeTrue())
 			Eventually(func() bool {
@@ -75,7 +75,8 @@ var _ = Describe("Metadata", func() {
 			Expect(groupSchema).ShouldNot(BeNil())
 			groupSchema.ResourceOpts.ShardNum = 4
 
-			Expect(svcs.metadataService.GroupRegistry().UpdateGroup(context.TODO(), groupSchema)).Should(Succeed())
+			_, updateGroupErr := svcs.metadataService.GroupRegistry().UpdateGroup(context.TODO(), groupSchema)
+			Expect(updateGroupErr).ShouldNot(HaveOccurred())
 
 			Eventually(func() bool {
 				group, ok := svcs.measure.LoadGroup("sw_metric")
@@ -98,7 +99,7 @@ var _ = Describe("Metadata", func() {
 			}).WithTimeout(flags.EventuallyTimeout).Should(BeTrue())
 		})
 		It("should close the measure", func() {
-			deleted, err := svcs.metadataService.MeasureRegistry().DeleteMeasure(context.TODO(), &commonv1.Metadata{
+			deleted, _, err := svcs.metadataService.MeasureRegistry().DeleteMeasure(context.TODO(), &commonv1.Metadata{
 				Name:  "service_cpm_minute",
 				Group: "sw_metric",
 			})
@@ -208,8 +209,8 @@ var _ = Describe("Metadata", func() {
 						Tags: []string{"new_tag"},
 						Type: databasev1.IndexRule_TYPE_INVERTED,
 					}
-					err := svcs.metadataService.IndexRuleRegistry().CreateIndexRule(context.TODO(), indexRule)
-					Expect(err).ShouldNot(HaveOccurred())
+					_, createIRErr := svcs.metadataService.IndexRuleRegistry().CreateIndexRule(context.TODO(), indexRule)
+					Expect(createIRErr).ShouldNot(HaveOccurred())
 					indexRuleBinding := &databasev1.IndexRuleBinding{
 						Metadata: &commonv1.Metadata{
 							Name:  "service_cpm_minute_new_tag",
@@ -223,8 +224,8 @@ var _ = Describe("Metadata", func() {
 						BeginAt:  timestamppb.New(timestamp.NowMilli().Add(-time.Hour)),
 						ExpireAt: timestamppb.New(timestamp.NowMilli().Add(time.Hour)),
 					}
-					err = svcs.metadataService.IndexRuleBindingRegistry().CreateIndexRuleBinding(context.TODO(), indexRuleBinding)
-					Expect(err).ShouldNot(HaveOccurred())
+					_, createIRBErr := svcs.metadataService.IndexRuleBindingRegistry().CreateIndexRuleBinding(context.TODO(), indexRuleBinding)
+					Expect(createIRBErr).ShouldNot(HaveOccurred())
 
 					Eventually(func() bool {
 						val, err := svcs.measure.Measure(&commonv1.Metadata{
@@ -735,7 +736,7 @@ func setupSchemaChangeMeasure(svcs *services, measureName string, opts measureSe
 
 	return &measureSchemaChangeEnv{
 		cleanup: func() {
-			_, _ = svcs.metadataService.MeasureRegistry().DeleteMeasure(ctx, &commonv1.Metadata{
+			_, _, _ = svcs.metadataService.MeasureRegistry().DeleteMeasure(ctx, &commonv1.Metadata{
 				Name:  measureName,
 				Group: schemaChangeGroupName,
 			})
diff --git a/banyand/measure/metrics.go b/banyand/measure/metrics.go
index ad38f3a5f..9e4e00dc3 100644
--- a/banyand/measure/metrics.go
+++ b/banyand/measure/metrics.go
@@ -453,9 +453,9 @@ func (s *standalone) createNativeObservabilityGroup(ctx context.Context) error {
 			},
 		},
 	}
-	if err := s.metadata.GroupRegistry().CreateGroup(ctx, g); err != nil &&
-		!errors.Is(err, schema.ErrGRPCAlreadyExists) {
-		return errors.WithMessage(err, "failed to create native observability group")
+	if _, createGroupErr := s.metadata.GroupRegistry().CreateGroup(ctx, g); createGroupErr != nil &&
+		!errors.Is(createGroupErr, schema.ErrGRPCAlreadyExists) {
+		return errors.WithMessage(createGroupErr, "failed to create native observability group")
 	}
 	return nil
 }
diff --git a/banyand/measure/svc_data.go b/banyand/measure/svc_data.go
index 065ff220f..e4f776f9b 100644
--- a/banyand/measure/svc_data.go
+++ b/banyand/measure/svc_data.go
@@ -384,9 +384,9 @@ func (s *dataSVC) createDataNativeObservabilityGroup(ctx context.Context) error
 			},
 		},
 	}
-	if err := s.metadata.GroupRegistry().CreateGroup(ctx, g); err != nil &&
-		!errors.Is(err, schema.ErrGRPCAlreadyExists) {
-		return errors.WithMessage(err, "failed to create native observability group")
+	if _, createGroupErr := s.metadata.GroupRegistry().CreateGroup(ctx, g); createGroupErr != nil &&
+		!errors.Is(createGroupErr, schema.ErrGRPCAlreadyExists) {
+		return errors.WithMessage(createGroupErr, "failed to create native observability group")
 	}
 	return nil
 }
diff --git a/banyand/metadata/schema/property/cache.go b/banyand/metadata/schema/property/cache.go
index 2cb96ec66..c594c25da 100644
--- a/banyand/metadata/schema/property/cache.go
+++ b/banyand/metadata/schema/property/cache.go
@@ -19,6 +19,7 @@ package property
 
 import (
 	"sync"
+	"time"
 
 	"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
 )
@@ -28,23 +29,50 @@ type cacheEntry struct {
 	group          string
 	name           string
 	latestUpdateAt int64
+	modRevision    int64
 	kind           schema.Kind
 }
 
+// DefaultMaxTombstoneEntries bounds the size of the tombstone index. Each entry is
+// roughly a propID string plus an int64 deleteTime — at ~100 bytes apiece the default
+// caps memory at ~10MB. The cap is defensive: under pathological churn (e.g. rapid
+// create/delete loops) the GC loop alone would let the index grow to retention × rate,
+// which can exceed available memory before retention elapses.
+const DefaultMaxTombstoneEntries = 100_000
+
 type schemaCache struct {
 	entries        map[string]*cacheEntry
+	tombstoneIndex map[string]int64 // propID → deleteTime (unix nano)
 	latestUpdateAt int64
-	mu             sync.RWMutex
+	// notifiedModRevision is the monotonic watermark advanced only after all downstream
+	// schema event handlers (e.g. groupRepo, entityRepo, pkg/schema.schemaRepo) have been
+	// notified for a given mod_revision. The barrier reads this watermark so callers can
+	// rely on "AwaitRevisionApplied(R) returning true" meaning every downstream cache has
+	// processed all events up to R — not just the property cache.
+	notifiedModRevision int64
+	// maxTombstones bounds tombstoneIndex size; 0 means unlimited. When set, Delete
+	// evicts the oldest tombstone before recording a new one once the cap is reached.
+	// Eviction sacrifices retention for the smallest fraction of entries — preferable
+	// to OOM under a runaway delete workload.
+	maxTombstones int
+	mu            sync.RWMutex
 }
 
 func newSchemaCache() *schemaCache {
+	return newSchemaCacheWithLimit(DefaultMaxTombstoneEntries)
+}
+
+func newSchemaCacheWithLimit(maxTombstones int) *schemaCache {
 	return &schemaCache{
-		entries: make(map[string]*cacheEntry),
+		entries:        make(map[string]*cacheEntry),
+		tombstoneIndex: make(map[string]int64),
+		maxTombstones:  maxTombstones,
 	}
 }
 
 // Update inserts or updates an entry. Returns true if the entry was changed.
 // Only accepts if entry.latestUpdateAt > existing.latestUpdateAt or not exist.
+// A live entry supersedes any tombstone for the same propID.
 func (c *schemaCache) Update(propID string, entry *cacheEntry) bool {
 	c.mu.Lock()
 	defer c.mu.Unlock()
@@ -53,6 +81,7 @@ func (c *schemaCache) Update(propID string, entry *cacheEntry) bool {
 		return false
 	}
 	c.entries[propID] = entry
+	delete(c.tombstoneIndex, propID)
 	if entry.latestUpdateAt > c.latestUpdateAt {
 		c.latestUpdateAt = entry.latestUpdateAt
 	}
@@ -60,6 +89,10 @@ func (c *schemaCache) Update(propID string, entry *cacheEntry) bool {
 }
 
 // Delete removes an entry. Only deletes if revision >= entry.latestUpdateAt.
+// When an entry is removed, the propID is recorded in tombstoneIndex so the
+// GC loop can later purge it from the client cache after tombstoneRetention elapses.
+// If maxTombstones is set and the index is at capacity, the oldest tombstone
+// is evicted before the new one is recorded so the index never grows unbounded.
 func (c *schemaCache) Delete(propID string, revision int64) bool {
 	c.mu.Lock()
 	defer c.mu.Unlock()
@@ -71,12 +104,63 @@ func (c *schemaCache) Delete(propID string, revision int64) bool {
 		return false
 	}
 	delete(c.entries, propID)
+	if revision > 0 {
+		if _, alreadyTracked := c.tombstoneIndex[propID]; !alreadyTracked {
+			c.evictOldestTombstoneIfFull()
+		}
+		c.tombstoneIndex[propID] = revision
+	}
 	if revision > c.latestUpdateAt {
 		c.latestUpdateAt = revision
 	}
 	return true
 }
 
+// evictOldestTombstoneIfFull removes the tombstone with the smallest deleteTime
+// when the index has reached maxTombstones. Caller must hold c.mu.
+func (c *schemaCache) evictOldestTombstoneIfFull() {
+	if c.maxTombstones <= 0 || len(c.tombstoneIndex) < c.maxTombstones {
+		return
+	}
+	var oldestID string
+	var oldestTime int64
+	first := true
+	for pid, t := range c.tombstoneIndex {
+		if first || t < oldestTime {
+			oldestID = pid
+			oldestTime = t
+			first = false
+		}
+	}
+	if oldestID != "" {
+		delete(c.tombstoneIndex, oldestID)
+	}
+}
+
+// TombstoneEntries returns propIDs of tombstone entries whose deleteTime is older
+// than retention and are not superseded by a live entry in the cache.
+func (c *schemaCache) TombstoneEntries(retention time.Duration) []string {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	cutoff := time.Now().Add(-retention).UnixNano()
+	var expired []string
+	for propID, deleteTime := range c.tombstoneIndex {
+		if deleteTime <= cutoff {
+			if _, live := c.entries[propID]; !live {
+				expired = append(expired, propID)
+			}
+		}
+	}
+	return expired
+}
+
+// RemoveTombstone removes a propID from the tombstone index.
+func (c *schemaCache) RemoveTombstone(propID string) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	delete(c.tombstoneIndex, propID)
+}
+
 // GetMaxRevision returns the global max revision.
 func (c *schemaCache) GetMaxRevision() int64 {
 	c.mu.RLock()
@@ -84,6 +168,60 @@ func (c *schemaCache) GetMaxRevision() int64 {
 	return c.latestUpdateAt
 }
 
+// GetMaxModRevision returns the monotonic watermark of the highest mod_revision
+// fully processed by every downstream schema event handler. Events with
+// mod_revision <= this value are guaranteed observable in all handler caches.
+func (c *schemaCache) GetMaxModRevision() int64 {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	return c.notifiedModRevision
+}
+
+// GetKeyModRevision returns the modRevision for the given propID, but only once
+// that entry's mod_revision has been fully notified to every handler. An entry
+// present in the cache map but not yet notified downstream reports (0, false)
+// so barrier callers correctly wait for handler-side coherence.
+func (c *schemaCache) GetKeyModRevision(propID string) (int64, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	entry, ok := c.entries[propID]
+	if !ok {
+		return 0, false
+	}
+	if entry.modRevision > c.notifiedModRevision {
+		return 0, false
+	}
+	return entry.modRevision, true
+}
+
+// AdvanceNotified pushes the downstream-handler watermark forward monotonically.
+// Callers must invoke this only after notifyHandlers returns for the given revision,
+// so downstream caches (groupRepo, entityRepo, pkg/schema.schemaRepo, …) are coherent
+// with the property cache at the moment the watermark advances.
+func (c *schemaCache) AdvanceNotified(rev int64) {
+	if rev <= 0 {
+		return
+	}
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	if rev > c.notifiedModRevision {
+		c.notifiedModRevision = rev
+	}
+}
+
+// GetModRevisionByKey returns the modRevision for the first cached entry that matches
+// kind, group, and name. The second return value is false when no matching entry exists.
+func (c *schemaCache) GetModRevisionByKey(kind schema.Kind, group, name string) (int64, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	for _, entry := range c.entries {
+		if entry.kind == kind && entry.group == group && entry.name == name {
+			return entry.modRevision, true
+		}
+	}
+	return 0, false
+}
+
 // Get returns a copy of the entry for the given propID, or nil if not found.
 func (c *schemaCache) Get(propID string) *cacheEntry {
 	c.mu.RLock()
diff --git a/banyand/metadata/schema/property/cache_test.go b/banyand/metadata/schema/property/cache_test.go
index 322c0a3bc..91cc57c9c 100644
--- a/banyand/metadata/schema/property/cache_test.go
+++ b/banyand/metadata/schema/property/cache_test.go
@@ -207,6 +207,61 @@ func TestSchemaCache_DeleteLastEntryRemovesKind(t *testing.T) {
 	assert.Empty(t, c.GetEntriesByKind(schema.KindStream))
 }
 
+// TestSchemaCache_TombstoneCap_EvictsOldestWhenFull verifies that Delete drops the
+// oldest tombstone (lowest deleteTime) once tombstoneIndex reaches maxTombstones,
+// so the index never grows beyond the cap.
+func TestSchemaCache_TombstoneCap_EvictsOldestWhenFull(t *testing.T) {
+	c := newSchemaCacheWithLimit(2)
+	c.Update("stream_g1/s1", &cacheEntry{group: "g1", name: "s1", latestUpdateAt: 10, kind: schema.KindStream})
+	c.Update("stream_g1/s2", &cacheEntry{group: "g1", name: "s2", latestUpdateAt: 20, kind: schema.KindStream})
+	c.Update("stream_g1/s3", &cacheEntry{group: "g1", name: "s3", latestUpdateAt: 30, kind: schema.KindStream})
+
+	require.True(t, c.Delete("stream_g1/s1", 100), "first delete should record a tombstone")
+	require.True(t, c.Delete("stream_g1/s2", 200), "second delete fills the cap")
+	require.True(t, c.Delete("stream_g1/s3", 300), "third delete must evict the oldest before recording")
+
+	assert.Len(t, c.tombstoneIndex, 2, "tombstone index must respect maxTombstones")
+	_, s1Tracked := c.tombstoneIndex["stream_g1/s1"]
+	assert.False(t, s1Tracked, "oldest tombstone (s1, deleteTime=100) must have been evicted")
+	_, s2Tracked := c.tombstoneIndex["stream_g1/s2"]
+	_, s3Tracked := c.tombstoneIndex["stream_g1/s3"]
+	assert.True(t, s2Tracked, "newer tombstones must be retained")
+	assert.True(t, s3Tracked, "newer tombstones must be retained")
+}
+
+// TestSchemaCache_TombstoneCap_Disabled_NoEviction verifies that maxTombstones=0
+// disables the cap entirely so the index grows without eviction.
+func TestSchemaCache_TombstoneCap_Disabled_NoEviction(t *testing.T) {
+	c := newSchemaCacheWithLimit(0)
+	for i := 0; i < 5; i++ {
+		propID := "stream_g1/s" + string(rune('a'+i))
+		c.Update(propID, &cacheEntry{group: "g1", name: "s", latestUpdateAt: int64(i + 1), kind: schema.KindStream})
+		require.True(t, c.Delete(propID, int64(100*(i+1))))
+	}
+	assert.Len(t, c.tombstoneIndex, 5, "with cap disabled all tombstones must persist")
+}
+
+// TestSchemaCache_TombstoneCap_RedeleteSameKey_NoEviction verifies that deleting an
+// already-tombstoned propID does not consume a slot — it updates the existing entry
+// rather than appending, so eviction does not fire.
+func TestSchemaCache_TombstoneCap_RedeleteSameKey_NoEviction(t *testing.T) {
+	c := newSchemaCacheWithLimit(2)
+	c.Update("stream_g1/s1", &cacheEntry{group: "g1", name: "s1", latestUpdateAt: 10, kind: schema.KindStream})
+	c.Update("stream_g1/s2", &cacheEntry{group: "g1", name: "s2", latestUpdateAt: 20, kind: schema.KindStream})
+
+	require.True(t, c.Delete("stream_g1/s1", 100))
+	require.True(t, c.Delete("stream_g1/s2", 200))
+
+	// Re-create then re-delete s1 — the tombstone slot is reused, not freshly allocated.
+	c.Update("stream_g1/s1", &cacheEntry{group: "g1", name: "s1", latestUpdateAt: 30, kind: schema.KindStream})
+	require.True(t, c.Delete("stream_g1/s1", 300))
+
+	assert.Len(t, c.tombstoneIndex, 2, "redelete of an existing tombstone must not evict")
+	_, s2Tracked := c.tombstoneIndex["stream_g1/s2"]
+	assert.True(t, s2Tracked, "s2 must be retained — eviction should not have fired")
+	assert.Equal(t, int64(300), c.tombstoneIndex["stream_g1/s1"], "s1 deleteTime must reflect the latest delete")
+}
+
 func TestSchemaCache_GetCachedKindsConsistent(t *testing.T) {
 	c := newSchemaCache()
 	c.Update("stream_g1/s1", &cacheEntry{group: "g1", name: "s1", latestUpdateAt: 10, kind: schema.KindStream})
diff --git a/banyand/metadata/schema/property/client.go b/banyand/metadata/schema/property/client.go
index 098eddbd7..50c6cf911 100644
--- a/banyand/metadata/schema/property/client.go
+++ b/banyand/metadata/schema/property/client.go
@@ -64,6 +64,10 @@ var _ schema.Registry = (*SchemaRegistry)(nil)
 
 var errNoActiveServers = fmt.Errorf("no active schema servers available")
 
+// DefaultTombstoneRetention is the default duration that tombstone entries are
+// retained in the client cache before the GC loop removes them.
+const DefaultTombstoneRetention = 7 * 24 * time.Hour
+
 // ClientConfig holds configuration for the property-based schema registry client.
 type ClientConfig struct {
 	OMR                 observability.MetricsRegistry
@@ -76,6 +80,15 @@ type ClientConfig struct {
 	WatchMaxBackoff     time.Duration
 	InitWaitTime        time.Duration
 	HealthCheckInterval time.Duration
+	// TombstoneRetention is how long tombstone entries are kept in the client cache
+	// before the GC loop purges them. Defaults to DefaultTombstoneRetention (7 days).
+	// Should match the schema server's tombstone-retention setting.
+	TombstoneRetention time.Duration
+	// MaxTombstoneEntries caps the in-memory tombstone index. Defaults to
+	// DefaultMaxTombstoneEntries (100k). When the cap is reached, the oldest
+	// tombstone is evicted to admit a new delete — defends against OOM under
+	// pathological churn that exceeds the GC interval.
+	MaxTombstoneEntries int
 	FullReconcileEvery  uint64
 	MaxRecvMsgSize      int
 	TLSEnabled          bool
@@ -120,6 +133,7 @@ type SchemaRegistry struct {
 	syncInterval       time.Duration
 	syncTimeout        time.Duration
 	watchMaxBackoff    time.Duration
+	tombstoneRetention time.Duration
 	fullReconcileEvery uint64
 	syncRound          uint64
 	mux                sync.RWMutex
@@ -174,17 +188,26 @@ func NewSchemaRegistryClient(cfg *ClientConfig) (*SchemaRegistry, error) {
 	if watchMaxBackoff == 0 {
 		watchMaxBackoff = defaultWatchMaxBackoff
 	}
+	tombstoneRetention := cfg.TombstoneRetention
+	if tombstoneRetention <= 0 {
+		tombstoneRetention = DefaultTombstoneRetention
+	}
+	maxTombstones := cfg.MaxTombstoneEntries
+	if maxTombstones <= 0 {
+		maxTombstones = DefaultMaxTombstoneEntries
+	}
 	reg := &SchemaRegistry{
 		connMgr:            connMgr,
 		closer:             run.NewCloser(1),
 		l:                  l,
-		cache:              newSchemaCache(),
+		cache:              newSchemaCacheWithLimit(maxTombstones),
 		caCertReloader:     caCertReloader,
 		handlers:           make(map[schema.Kind][]schema.EventHandler),
 		watchSessions:      make(map[string]*watchSession),
 		syncInterval:       syncInterval,
 		syncTimeout:        syncTimeout,
 		watchMaxBackoff:    watchMaxBackoff,
+		tombstoneRetention: tombstoneRetention,
 		fullReconcileEvery: fullReconcileEvery,
 	}
 	handler.registry = reg
@@ -355,11 +378,14 @@ func (r *SchemaRegistry) querySchemasFromClient(ctx context.Context,
 	return results, nil
 }
 
-func (r *SchemaRegistry) queryAndRepairSchemas(ctx context.Context,
+// collectSchemas queries all active nodes for the given property query and returns the best
+// schema per propID together with the set of nodes that responded. It does NOT repair
+// inconsistencies — call repairInconsistentNodes separately when repair is desired.
+func (r *SchemaRegistry) collectSchemas(ctx context.Context,
 	query *propertyv1.QueryRequest,
-) (map[string]*propInfo, error) {
-	propMap := make(map[string]*propInfo)
-	respondedNodes := make(map[string]bool)
+) (propMap map[string]*propInfo, respondedNodes map[string]bool, err error) {
+	propMap = make(map[string]*propInfo)
+	respondedNodes = make(map[string]bool)
 	var mu sync.Mutex
 	broadcastErr := r.broadcastAll(func(currentNode string, c *schemaClient) error {
 		schemas, queryErr := r.querySchemasFromClient(ctx, c.management, query)
@@ -391,10 +417,20 @@ func (r *SchemaRegistry) queryAndRepairSchemas(ctx context.Context,
 		}
 		return nil
 	})
-	r.repairInconsistentNodes(ctx, respondedNodes, propMap)
 	if broadcastErr != nil && len(propMap) == 0 {
-		return nil, broadcastErr
+		err = broadcastErr
 	}
+	return
+}
+
+func (r *SchemaRegistry) queryAndRepairSchemas(ctx context.Context,
+	query *propertyv1.QueryRequest,
+) (map[string]*propInfo, error) {
+	propMap, respondedNodes, collectErr := r.collectSchemas(ctx, query)
+	if collectErr != nil {
+		return nil, collectErr
+	}
+	r.repairInconsistentNodes(ctx, respondedNodes, propMap)
 	return propMap, nil
 }
 
@@ -468,8 +504,9 @@ func (r *SchemaRegistry) listSchemas(ctx context.Context, kind schema.Kind,
 	return result, nil
 }
 
-func (r *SchemaRegistry) broadcastDelete(ctx context.Context, kind schema.Kind, group, name string) (bool, error) {
-	req := buildDeleteRequest(kind, group, name)
+func (r *SchemaRegistry) broadcastDelete(ctx context.Context, kind schema.Kind, group, name string) (bool, int64, error) {
+	deleteTime := time.Now().UnixNano()
+	req := buildDeleteRequest(kind, group, name, deleteTime)
 	var found atomic.Bool
 	writeErr := r.broadcastAll(func(_ string, c *schemaClient) error {
 		resp, rpcErr := c.management.DeleteSchema(ctx, req)
@@ -481,7 +518,10 @@ func (r *SchemaRegistry) broadcastDelete(ctx context.Context, kind schema.Kind,
 		}
 		return nil
 	})
-	return found.Load(), writeErr
+	if writeErr != nil {
+		return found.Load(), 0, writeErr
+	}
+	return found.Load(), deleteTime, nil
 }
 
 func getResource[T proto.Message](ctx context.Context, r *SchemaRegistry,
@@ -552,6 +592,43 @@ func (r *SchemaRegistry) validateGroup(ctx context.Context, kind schema.Kind, gr
 	}
 }
 
+// getSchemaWithDeleteTime returns the best stored property for (kind, group, name) together with
+// its delete time in unix nanoseconds (non-zero when the entry is a tombstone).
+// It does NOT trigger repairs so it is safe to call inside createResource before broadcastInsert.
+func (r *SchemaRegistry) getSchemaWithDeleteTime(ctx context.Context, kind schema.Kind, group, name string) (*propertyv1.Property, int64, error) {
+	query := buildSchemaQuery(kind, group, name, 0)
+	propMap, _, collectErr := r.collectSchemas(ctx, query)
+	if collectErr != nil {
+		return nil, 0, collectErr
+	}
+	propID := query.Ids[0]
+	info := propMap[propID]
+	if info == nil || info.best == nil {
+		return nil, 0, nil
+	}
+	return info.best.property, info.best.deleteTime, nil
+}
+
+// checkTombstoneInvariant rejects a Create when a tombstone exists for the same (group, name)
+// and spec.UpdatedAt <= tombstone.deleteTime.
+func (r *SchemaRegistry) checkTombstoneInvariant(ctx context.Context, kind schema.Kind, group, name string, spec proto.Message) error {
+	_, deleteTime, getErr := r.getSchemaWithDeleteTime(ctx, kind, group, name)
+	if getErr != nil {
+		return getErr
+	}
+	if deleteTime == 0 {
+		return nil
+	}
+	updatedAt, updErr := getUpdatedAtFromSpec(kind, spec)
+	if updErr != nil || updatedAt == nil {
+		return nil
+	}
+	if updatedAt.AsTime().UnixNano() <= deleteTime {
+		return status.Error(codes.InvalidArgument, "updated_at_before_tombstone")
+	}
+	return nil
+}
+
 func createResource[T proto.Message](ctx context.Context, r *SchemaRegistry,
 	kind schema.Kind, spec T,
 ) error {
@@ -562,6 +639,15 @@ func createResource[T proto.Message](ctx context.Context, r *SchemaRegistry,
 	if validateErr := r.validateGroup(ctx, kind, metadata.GetGroup()); validateErr != nil {
 		return validateErr
 	}
+	if tombstoneErr := r.checkTombstoneInvariant(ctx, kind, metadata.GetGroup(), metadata.GetName(), spec); tombstoneErr != nil {
+		return tombstoneErr
+	}
+	// Stamp created_at = UpdatedAt when not already set by the caller.
+	if getCreatedAtFromSpec(kind, spec) == nil {
+		if updatedAt, updErr := getUpdatedAtFromSpec(kind, spec); updErr == nil && updatedAt != nil {
+			setCreatedAtOnSpec(kind, spec, updatedAt)
+		}
+	}
 	prop, convErr := SchemaToProperty(kind, spec)
 	if convErr != nil {
 		return convErr
@@ -599,6 +685,10 @@ func updateResource[T proto.Message](ctx context.Context, r *SchemaRegistry,
 			return validateErr
 		}
 	}
+	// Preserve created_at from the stored schema; the caller must not override it.
+	if prevCreatedAt := getCreatedAtFromSpec(kind, prev); prevCreatedAt != nil {
+		setCreatedAtOnSpec(kind, spec, prevCreatedAt)
+	}
 	if checker, checkerOk := schema.CheckerMap[kind]; checkerOk && checker(prev, spec) {
 		return nil
 	}
@@ -650,7 +740,7 @@ func (r *SchemaRegistry) UpdateStream(ctx context.Context, stream *databasev1.St
 }
 
 // DeleteStream deletes a stream schema.
-func (r *SchemaRegistry) DeleteStream(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteStream(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
 	return r.broadcastDelete(ctx, schema.KindStream, metadata.GetGroup(), metadata.GetName())
 }
 
@@ -702,7 +792,7 @@ func (r *SchemaRegistry) UpdateMeasure(ctx context.Context, measure *databasev1.
 }
 
 // DeleteMeasure deletes a measure schema.
-func (r *SchemaRegistry) DeleteMeasure(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteMeasure(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
 	return r.broadcastDelete(ctx, schema.KindMeasure, metadata.GetGroup(), metadata.GetName())
 }
 
@@ -759,7 +849,7 @@ func (r *SchemaRegistry) UpdateTrace(ctx context.Context, trace *databasev1.Trac
 }
 
 // DeleteTrace deletes a trace schema.
-func (r *SchemaRegistry) DeleteTrace(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteTrace(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
 	return r.broadcastDelete(ctx, schema.KindTrace, metadata.GetGroup(), metadata.GetName())
 }
 
@@ -774,32 +864,32 @@ func (r *SchemaRegistry) ListGroup(ctx context.Context) ([]*commonv1.Group, erro
 }
 
 // CreateGroup creates a group schema.
-func (r *SchemaRegistry) CreateGroup(ctx context.Context, group *commonv1.Group) error {
+func (r *SchemaRegistry) CreateGroup(ctx context.Context, group *commonv1.Group) (int64, error) {
 	if validateErr := validate.Group(group); validateErr != nil {
-		return validateErr
+		return 0, validateErr
 	}
 	now := time.Now().UnixNano()
 	group.Metadata.ModRevision = now
 	group.UpdatedAt = timestamppb.Now()
-	return createResource(ctx, r, schema.KindGroup, group)
+	return now, createResource(ctx, r, schema.KindGroup, group)
 }
 
 // UpdateGroup updates a group schema.
-func (r *SchemaRegistry) UpdateGroup(ctx context.Context, group *commonv1.Group) error {
+func (r *SchemaRegistry) UpdateGroup(ctx context.Context, group *commonv1.Group) (int64, error) {
 	if validateErr := validate.Group(group); validateErr != nil {
-		return validateErr
+		return 0, validateErr
 	}
 	now := time.Now().UnixNano()
 	group.Metadata.ModRevision = now
 	group.UpdatedAt = timestamppb.Now()
-	return updateResource(ctx, r, schema.KindGroup, group)
+	return now, updateResource(ctx, r, schema.KindGroup, group)
 }
 
 // DeleteGroup deletes a group and all its resources.
-func (r *SchemaRegistry) DeleteGroup(ctx context.Context, group string) (bool, error) {
+func (r *SchemaRegistry) DeleteGroup(ctx context.Context, group string) (bool, int64, error) {
 	_, groupErr := r.GetGroup(ctx, group)
 	if groupErr != nil {
-		return false, fmt.Errorf("%s: %w", group, groupErr)
+		return false, 0, fmt.Errorf("%s: %w", group, groupErr)
 	}
 	var deleteErr error
 	for _, kind := range schema.AllKinds() {
@@ -816,17 +906,19 @@ func (r *SchemaRegistry) DeleteGroup(ctx context.Context, group string) (bool, e
 			if parsed.Name == "" {
 				continue
 			}
-			_, broadcastErr := r.broadcastDelete(ctx, kind, group, parsed.Name)
-			if broadcastErr != nil {
+			if _, _, broadcastErr := r.broadcastDelete(ctx, kind, group, parsed.Name); broadcastErr != nil {
 				deleteErr = multierr.Append(deleteErr, broadcastErr)
 			}
 		}
 	}
-	_, broadcastErr := r.broadcastDelete(ctx, schema.KindGroup, "", group)
+	_, deleteTime, broadcastErr := r.broadcastDelete(ctx, schema.KindGroup, "", group)
 	if broadcastErr != nil {
 		deleteErr = multierr.Append(deleteErr, broadcastErr)
 	}
-	return deleteErr == nil, deleteErr
+	if deleteErr != nil {
+		return false, 0, deleteErr
+	}
+	return true, deleteTime, nil
 }
 
 // GetIndexRule retrieves an index rule schema.
@@ -840,39 +932,39 @@ func (r *SchemaRegistry) ListIndexRule(ctx context.Context, opt schema.ListOpt)
 }
 
 // CreateIndexRule creates an index rule schema.
-func (r *SchemaRegistry) CreateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) error {
+func (r *SchemaRegistry) CreateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) (int64, error) {
 	if indexRule.Metadata.Id == 0 {
 		indexRule.Metadata.Id = generateCRC32ID(indexRule.Metadata.Group, indexRule.Metadata.Name)
 	}
 	if validateErr := validate.IndexRule(indexRule); validateErr != nil {
-		return validateErr
+		return 0, validateErr
 	}
 	now := time.Now().UnixNano()
 	indexRule.Metadata.ModRevision = now
 	indexRule.UpdatedAt = timestamppb.Now()
-	return createResource(ctx, r, schema.KindIndexRule, indexRule)
+	return now, createResource(ctx, r, schema.KindIndexRule, indexRule)
 }
 
 // UpdateIndexRule updates an index rule schema.
-func (r *SchemaRegistry) UpdateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) error {
+func (r *SchemaRegistry) UpdateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) (int64, error) {
 	if indexRule.Metadata.Id == 0 {
 		existing, getErr := r.GetIndexRule(ctx, indexRule.Metadata)
 		if getErr != nil {
-			return getErr
+			return 0, getErr
 		}
 		indexRule.Metadata.Id = existing.Metadata.Id
 	}
 	if validateErr := validate.IndexRule(indexRule); validateErr != nil {
-		return validateErr
+		return 0, validateErr
 	}
 	now := time.Now().UnixNano()
 	indexRule.Metadata.ModRevision = now
 	indexRule.UpdatedAt = timestamppb.Now()
-	return updateResource(ctx, r, schema.KindIndexRule, indexRule)
+	return now, updateResource(ctx, r, schema.KindIndexRule, indexRule)
 }
 
 // DeleteIndexRule deletes an index rule schema.
-func (r *SchemaRegistry) DeleteIndexRule(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteIndexRule(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
 	return r.broadcastDelete(ctx, schema.KindIndexRule, metadata.GetGroup(), metadata.GetName())
 }
 
@@ -887,29 +979,29 @@ func (r *SchemaRegistry) ListIndexRuleBinding(ctx context.Context, opt schema.Li
 }
 
 // CreateIndexRuleBinding creates an index rule binding schema.
-func (r *SchemaRegistry) CreateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) error {
+func (r *SchemaRegistry) CreateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) (int64, error) {
 	if validateErr := validate.IndexRuleBinding(indexRuleBinding); validateErr != nil {
-		return validateErr
+		return 0, validateErr
 	}
 	now := time.Now().UnixNano()
 	indexRuleBinding.Metadata.ModRevision = now
 	indexRuleBinding.UpdatedAt = timestamppb.Now()
-	return createResource(ctx, r, schema.KindIndexRuleBinding, indexRuleBinding)
+	return now, createResource(ctx, r, schema.KindIndexRuleBinding, indexRuleBinding)
 }
 
 // UpdateIndexRuleBinding updates an index rule binding schema.
-func (r *SchemaRegistry) UpdateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) error {
+func (r *SchemaRegistry) UpdateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) (int64, error) {
 	if validateErr := validate.IndexRuleBinding(indexRuleBinding); validateErr != nil {
-		return validateErr
+		return 0, validateErr
 	}
 	now := time.Now().UnixNano()
 	indexRuleBinding.Metadata.ModRevision = now
 	indexRuleBinding.UpdatedAt = timestamppb.Now()
-	return updateResource(ctx, r, schema.KindIndexRuleBinding, indexRuleBinding)
+	return now, updateResource(ctx, r, schema.KindIndexRuleBinding, indexRuleBinding)
 }
 
 // DeleteIndexRuleBinding deletes an index rule binding schema.
-func (r *SchemaRegistry) DeleteIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
 	return r.broadcastDelete(ctx, schema.KindIndexRuleBinding, metadata.GetGroup(), metadata.GetName())
 }
 
@@ -924,29 +1016,29 @@ func (r *SchemaRegistry) ListTopNAggregation(ctx context.Context, opt schema.Lis
 }
 
 // CreateTopNAggregation creates a TopN aggregation schema.
-func (r *SchemaRegistry) CreateTopNAggregation(ctx context.Context, topN *databasev1.TopNAggregation) error {
+func (r *SchemaRegistry) CreateTopNAggregation(ctx context.Context, topN *databasev1.TopNAggregation) (int64, error) {
 	if validateErr := validate.TopNAggregation(topN); validateErr != nil {
-		return validateErr
+		return 0, validateErr
 	}
 	now := time.Now().UnixNano()
 	topN.Metadata.ModRevision = now
 	topN.UpdatedAt = timestamppb.Now()
-	return createResource(ctx, r, schema.KindTopNAggregation, topN)
+	return now, createResource(ctx, r, schema.KindTopNAggregation, topN)
 }
 
 // UpdateTopNAggregation updates a TopN aggregation schema.
-func (r *SchemaRegistry) UpdateTopNAggregation(ctx context.Context, topN *databasev1.TopNAggregation) error {
+func (r *SchemaRegistry) UpdateTopNAggregation(ctx context.Context, topN *databasev1.TopNAggregation) (int64, error) {
 	if validateErr := validate.TopNAggregation(topN); validateErr != nil {
-		return validateErr
+		return 0, validateErr
 	}
 	now := time.Now().UnixNano()
 	topN.Metadata.ModRevision = now
 	topN.UpdatedAt = timestamppb.Now()
-	return updateResource(ctx, r, schema.KindTopNAggregation, topN)
+	return now, updateResource(ctx, r, schema.KindTopNAggregation, topN)
 }
 
 // DeleteTopNAggregation deletes a TopN aggregation schema.
-func (r *SchemaRegistry) DeleteTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
 	return r.broadcastDelete(ctx, schema.KindTopNAggregation, metadata.GetGroup(), metadata.GetName())
 }
 
@@ -981,7 +1073,7 @@ func (r *SchemaRegistry) UpdateProperty(ctx context.Context, property *databasev
 }
 
 // DeleteProperty deletes a property schema.
-func (r *SchemaRegistry) DeleteProperty(ctx context.Context, metadata *commonv1.Metadata) (bool, error) {
+func (r *SchemaRegistry) DeleteProperty(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error) {
 	return r.broadcastDelete(ctx, schema.KindProperty, metadata.GetGroup(), metadata.GetName())
 }
 
@@ -1064,6 +1156,7 @@ func (r *SchemaRegistry) Start(ctx context.Context) error {
 	// registered. This explicit replay ensures handlers see every entry.
 	r.notifyHandlersFromCache()
 	go r.syncLoop(ctx)
+	go r.tombstoneGCLoop(ctx)
 	return nil
 }
 
@@ -1093,6 +1186,44 @@ func (r *SchemaRegistry) syncLoop(ctx context.Context) {
 	}
 }
 
+func (r *SchemaRegistry) tombstoneGCLoop(ctx context.Context) {
+	if !r.closer.AddRunning() {
+		return
+	}
+	defer r.closer.Done()
+	// Aim to fire ~10 times per retention window so an expired tombstone is purged
+	// well within retention. Cap at 1 minute so production retention (e.g. 7d) still
+	// reacts within bounded wall-clock time. Floor at 100ms so very short retentions
+	// (e.g. integration tests at 2s) actually exercise the GC path without thrashing.
+	interval := r.tombstoneRetention / 10
+	if interval > time.Minute {
+		interval = time.Minute
+	}
+	if interval < 100*time.Millisecond {
+		interval = 100 * time.Millisecond
+	}
+	ticker := time.NewTicker(interval)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-r.closer.CloseNotify():
+			return
+		case <-ticker.C:
+			r.runTombstoneGC()
+		}
+	}
+}
+
+func (r *SchemaRegistry) runTombstoneGC() {
+	expired := r.cache.TombstoneEntries(r.tombstoneRetention)
+	for _, propID := range expired {
+		r.cache.RemoveTombstone(propID)
+		r.l.Debug().Str("propID", propID).Msg("tombstone GC: removed expired tombstone from cache")
+	}
+}
+
 func (r *SchemaRegistry) launchWatch(nodeName string, client *schemaClient) {
 	r.watchMu.Lock()
 	defer r.watchMu.Unlock()
@@ -1293,6 +1424,9 @@ func (r *SchemaRegistry) handleWatchEvent(resp *schemav1.WatchSchemasResponse) {
 				return
 			}
 			r.notifyHandlers(kind, md, true)
+			// Advance the barrier watermark past the delete event's mod_revision after handlers
+			// have processed it, keeping the barrier coherent with downstream caches.
+			r.cache.AdvanceNotified(prop.GetMetadata().GetModRevision())
 		}
 	case schemav1.SchemaEventType_SCHEMA_EVENT_TYPE_REPLAY_DONE, schemav1.SchemaEventType_SCHEMA_EVENT_TYPE_UNSPECIFIED:
 		// handled by processWatchSession, not expected here
@@ -1471,6 +1605,7 @@ func (r *SchemaRegistry) processInitialResourceFromProperty(kind schema.Kind, pr
 		group:          parsed.Group,
 		name:           parsed.Name,
 		latestUpdateAt: parsed.UpdatedAt,
+		modRevision:    prop.GetMetadata().GetModRevision(),
 		kind:           kind,
 	}
 	updated := r.cache.Update(propID, entry)
@@ -1485,6 +1620,11 @@ func (r *SchemaRegistry) processInitialResourceFromProperty(kind schema.Kind, pr
 			},
 			Spec: spec,
 		}, false)
+		// Advance the barrier watermark only after every handler (groupRepo, entityRepo,
+		// pkg/schema.schemaRepo, etc.) has been notified. Without this gate, the barrier
+		// could return applied=true while downstream caches still lag, letting a client
+		// issue a query that hits "group not found".
+		r.cache.AdvanceNotified(entry.modRevision)
 	}
 }
 
@@ -1500,6 +1640,9 @@ func (r *SchemaRegistry) handleDeletion(kind schema.Kind, propID string, entry *
 			},
 			Spec: entry.spec,
 		}, true)
+		// Advance the barrier watermark past the deleted entry's mod_revision so callers
+		// awaiting that revision see the delete once handlers have processed it.
+		r.cache.AdvanceNotified(entry.modRevision)
 	}
 }
 
@@ -1521,6 +1664,7 @@ func (r *SchemaRegistry) notifyHandlers(kind schema.Kind, md schema.Metadata, is
 func (r *SchemaRegistry) notifyHandlersFromCache() {
 	entries := r.cache.GetAllEntries()
 	r.l.Debug().Int("entryCount", len(entries)).Msg("notifyHandlersFromCache: replaying cached entries to handlers")
+	var maxRev int64
 	for _, entry := range entries {
 		r.notifyHandlers(entry.kind, schema.Metadata{
 			TypeMeta: schema.TypeMeta{
@@ -1530,6 +1674,16 @@ func (r *SchemaRegistry) notifyHandlersFromCache() {
 			},
 			Spec: entry.spec,
 		}, false)
+		if entry.modRevision > maxRev {
+			maxRev = entry.modRevision
+		}
+	}
+	// Advance the barrier watermark to cover every replayed entry. Without this, a server
+	// restart that hydrates the cache from the property store before handlers register
+	// leaves notifiedModRevision at zero — AwaitRevisionApplied(R) would block for any
+	// pre-restart revision even though handlers have processed it.
+	if maxRev > 0 {
+		r.cache.AdvanceNotified(maxRev)
 	}
 }
 
@@ -1538,6 +1692,34 @@ func (r *SchemaRegistry) ActiveNodeNames() []string {
 	return r.connMgr.ActiveNames()
 }
 
+// GetMaxModRevision returns the maximum modRevision across all cached entries.
+func (r *SchemaRegistry) GetMaxModRevision() int64 {
+	return r.cache.GetMaxModRevision()
+}
+
+// GetKeyModRevision returns the modRevision for the given propID.
+// The second return value is false when the propID is not in the live-entry map.
+func (r *SchemaRegistry) GetKeyModRevision(propID string) (int64, bool) {
+	return r.cache.GetKeyModRevision(propID)
+}
+
+// LatestModRevision returns the highest modRevision seen across all cached entries.
+func (r *SchemaRegistry) LatestModRevision() int64 {
+	return r.cache.GetMaxModRevision()
+}
+
+// ResourceRevision returns the modRevision for the cached entry matching (kind, group, name).
+// The second return value is false when no such entry is present in the local cache.
+func (r *SchemaRegistry) ResourceRevision(kind schema.Kind, group, name string) (int64, bool) {
+	return r.cache.GetModRevisionByKey(kind, group, name)
+}
+
+// IsAbsent returns true when the given (kind, group, name) triple is not present in the local cache.
+func (r *SchemaRegistry) IsAbsent(kind schema.Kind, group, name string) bool {
+	_, found := r.cache.GetModRevisionByKey(kind, group, name)
+	return !found
+}
+
 // Close stops all watchers and gracefully stops ConnManager.
 func (r *SchemaRegistry) Close() error {
 	if r.caCertReloader != nil {
diff --git a/banyand/metadata/schema/property/client_test.go b/banyand/metadata/schema/property/client_test.go
index ae36d44f4..37894f0bf 100644
--- a/banyand/metadata/schema/property/client_test.go
+++ b/banyand/metadata/schema/property/client_test.go
@@ -330,7 +330,8 @@ func testPropertyGroup(name string) *commonv1.Group {
 
 func createTestGroup(t *testing.T, reg *property.SchemaRegistry) {
 	t.Helper()
-	require.NoError(t, reg.CreateGroup(context.Background(), testGroup()))
+	_, createGroupErr := reg.CreateGroup(context.Background(), testGroup())
+	require.NoError(t, createGroupErr)
 }
 
 func TestGroupCRUD(t *testing.T) {
@@ -338,7 +339,8 @@ func TestGroupCRUD(t *testing.T) {
 	reg := newTestRegistry(t, addr)
 	ctx := context.Background()
 	g := testGroup()
-	require.NoError(t, reg.CreateGroup(ctx, g))
+	_, createGroupErr := reg.CreateGroup(ctx, g)
+	require.NoError(t, createGroupErr)
 	got, getErr := reg.GetGroup(ctx, "test-group")
 	require.NoError(t, getErr)
 	assert.Equal(t, "test-group", got.GetMetadata().GetName())
@@ -346,11 +348,12 @@ func TestGroupCRUD(t *testing.T) {
 	require.NoError(t, listErr)
 	assert.Len(t, groups, 1)
 	got.ResourceOpts.ShardNum = 4
-	require.NoError(t, reg.UpdateGroup(ctx, got))
+	_, updateGroupErr := reg.UpdateGroup(ctx, got)
+	require.NoError(t, updateGroupErr)
 	updated, getErr := reg.GetGroup(ctx, "test-group")
 	require.NoError(t, getErr)
 	assert.Equal(t, uint32(4), updated.GetResourceOpts().GetShardNum())
-	deleted, deleteErr := reg.DeleteGroup(ctx, "test-group")
+	deleted, _, deleteErr := reg.DeleteGroup(ctx, "test-group")
 	require.NoError(t, deleteErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetGroup(ctx, "test-group")
@@ -384,7 +387,7 @@ func TestStreamCRUD(t *testing.T) {
 	updated, getErr := reg.GetStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
 	require.NoError(t, getErr)
 	assert.Len(t, updated.GetTagFamilies()[0].GetTags(), 2)
-	deleted, deleteErr := reg.DeleteStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
+	deleted, _, deleteErr := reg.DeleteStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
 	require.NoError(t, deleteErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
@@ -406,7 +409,8 @@ func TestGroupUpdateNoOpSkipsBroadcast(t *testing.T) {
 	require.NotZero(t, storedModRev)
 
 	time.Sleep(2 * time.Millisecond)
-	require.NoError(t, reg.UpdateGroup(ctx, got))
+	_, updateNoOpErr := reg.UpdateGroup(ctx, got)
+	require.NoError(t, updateNoOpErr)
 
 	afterNoOp, getErr := reg.GetGroup(ctx, "test-group")
 	require.NoError(t, getErr)
@@ -415,7 +419,8 @@ func TestGroupUpdateNoOpSkipsBroadcast(t *testing.T) {
 
 	afterNoOp.ResourceOpts.ShardNum = 4
 	time.Sleep(2 * time.Millisecond)
-	require.NoError(t, reg.UpdateGroup(ctx, afterNoOp))
+	_, updateRealErr := reg.UpdateGroup(ctx, afterNoOp)
+	require.NoError(t, updateRealErr)
 	afterRealChange, getErr := reg.GetGroup(ctx, "test-group")
 	require.NoError(t, getErr)
 	assert.Greater(t, afterRealChange.GetMetadata().GetModRevision(), storedModRev,
@@ -496,7 +501,7 @@ func TestMeasureCRUD(t *testing.T) {
 	updated, getErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
 	require.NoError(t, getErr)
 	assert.Len(t, updated.GetFields(), 2)
-	deleted, deleteErr := reg.DeleteMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+	deleted, _, deleteErr := reg.DeleteMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
 	require.NoError(t, deleteErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
@@ -527,7 +532,7 @@ func TestTraceCRUD(t *testing.T) {
 	updated, getErr := reg.GetTrace(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-trace"})
 	require.NoError(t, getErr)
 	assert.Len(t, updated.GetTags(), 4)
-	deleted, deleteErr := reg.DeleteTrace(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-trace"})
+	deleted, _, deleteErr := reg.DeleteTrace(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-trace"})
 	require.NoError(t, deleteErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetTrace(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-trace"})
@@ -540,7 +545,8 @@ func TestIndexRuleCRUD(t *testing.T) {
 	ctx := context.Background()
 	createTestGroup(t, reg)
 	ir := testIndexRule("test-group")
-	require.NoError(t, reg.CreateIndexRule(ctx, ir))
+	_, createIRErr := reg.CreateIndexRule(ctx, ir)
+	require.NoError(t, createIRErr)
 	got, getErr := reg.GetIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
 	require.NoError(t, getErr)
 	assert.Equal(t, "test-index-rule", got.GetMetadata().GetName())
@@ -548,11 +554,12 @@ func TestIndexRuleCRUD(t *testing.T) {
 	require.NoError(t, listErr)
 	assert.Len(t, rules, 1)
 	got.Tags = append(got.Tags, "endpoint")
-	require.NoError(t, reg.UpdateIndexRule(ctx, got))
+	_, updateIRErr := reg.UpdateIndexRule(ctx, got)
+	require.NoError(t, updateIRErr)
 	updated, getErr := reg.GetIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
 	require.NoError(t, getErr)
 	assert.Len(t, updated.GetTags(), 2)
-	deleted, deleteErr := reg.DeleteIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
+	deleted, _, deleteErr := reg.DeleteIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
 	require.NoError(t, deleteErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
@@ -566,7 +573,8 @@ func TestIndexRuleCRUD_AutoGenerateCRC32ID(t *testing.T) {
 	createTestGroup(t, reg)
 	ir := testIndexRule("test-group")
 	ir.Metadata.Id = 0
-	require.NoError(t, reg.CreateIndexRule(ctx, ir))
+	_, createIRCRCErr := reg.CreateIndexRule(ctx, ir)
+	require.NoError(t, createIRCRCErr)
 	got, getErr := reg.GetIndexRule(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule"})
 	require.NoError(t, getErr)
 	assert.Greater(t, got.GetMetadata().GetId(), uint32(0))
@@ -578,7 +586,8 @@ func TestIndexRuleBindingCRUD(t *testing.T) {
 	ctx := context.Background()
 	createTestGroup(t, reg)
 	irb := testIndexRuleBinding("test-group")
-	require.NoError(t, reg.CreateIndexRuleBinding(ctx, irb))
+	_, createIRBErr := reg.CreateIndexRuleBinding(ctx, irb)
+	require.NoError(t, createIRBErr)
 	got, getErr := reg.GetIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
 	require.NoError(t, getErr)
 	assert.Equal(t, "test-index-rule-binding", got.GetMetadata().GetName())
@@ -586,11 +595,12 @@ func TestIndexRuleBindingCRUD(t *testing.T) {
 	require.NoError(t, listErr)
 	assert.Len(t, bindings, 1)
 	got.Rules = append(got.Rules, "another-rule")
-	require.NoError(t, reg.UpdateIndexRuleBinding(ctx, got))
+	_, updateIRBErr := reg.UpdateIndexRuleBinding(ctx, got)
+	require.NoError(t, updateIRBErr)
 	updated, getErr := reg.GetIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
 	require.NoError(t, getErr)
 	assert.Len(t, updated.GetRules(), 2)
-	deleted, deleteErr := reg.DeleteIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
+	deleted, _, deleteErr := reg.DeleteIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
 	require.NoError(t, deleteErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetIndexRuleBinding(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-index-rule-binding"})
@@ -603,7 +613,8 @@ func TestTopNAggregationCRUD(t *testing.T) {
 	ctx := context.Background()
 	createTestGroup(t, reg)
 	topn := testTopNAggregation("test-group")
-	require.NoError(t, reg.CreateTopNAggregation(ctx, topn))
+	_, createTopNErr := reg.CreateTopNAggregation(ctx, topn)
+	require.NoError(t, createTopNErr)
 	got, getErr := reg.GetTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
 	require.NoError(t, getErr)
 	assert.Equal(t, "test-topn", got.GetMetadata().GetName())
@@ -611,14 +622,15 @@ func TestTopNAggregationCRUD(t *testing.T) {
 	require.NoError(t, listErr)
 	assert.Len(t, topns, 1)
 	got.CountersNumber = 20
-	require.NoError(t, reg.UpdateTopNAggregation(ctx, got))
+	_, updateTopNErr := reg.UpdateTopNAggregation(ctx, got)
+	require.NoError(t, updateTopNErr)
 	updated, getErr := reg.GetTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
 	require.NoError(t, getErr)
 	assert.Equal(t, int32(20), updated.GetCountersNumber())
 	aggrs, aggrErr := reg.TopNAggregations(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
 	require.NoError(t, aggrErr)
 	assert.Len(t, aggrs, 1)
-	deleted, deleteErr := reg.DeleteTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
+	deleted, _, deleteErr := reg.DeleteTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
 	require.NoError(t, deleteErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetTopNAggregation(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-topn"})
@@ -629,7 +641,8 @@ func TestPropertyCRUD(t *testing.T) {
 	addr := startTestSchemaServer(t)
 	reg := newTestRegistry(t, addr)
 	ctx := context.Background()
-	require.NoError(t, reg.CreateGroup(ctx, testPropertyGroup("test-prop-group")))
+	_, createGroupErr := reg.CreateGroup(ctx, testPropertyGroup("test-prop-group"))
+	require.NoError(t, createGroupErr)
 	p := testProperty("test-prop-group")
 	require.NoError(t, reg.CreateProperty(ctx, p))
 	got, getErr := reg.GetProperty(ctx, &commonv1.Metadata{Group: "test-prop-group", Name: "test-property"})
@@ -639,7 +652,7 @@ func TestPropertyCRUD(t *testing.T) {
 	require.NoError(t, listErr)
 	assert.Len(t, props, 1)
 	require.NoError(t, reg.UpdateProperty(ctx, got))
-	deleted, deleteErr := reg.DeleteProperty(ctx, &commonv1.Metadata{Group: "test-prop-group", Name: "test-property"})
+	deleted, _, deleteErr := reg.DeleteProperty(ctx, &commonv1.Metadata{Group: "test-prop-group", Name: "test-property"})
 	require.NoError(t, deleteErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetProperty(ctx, &commonv1.Metadata{Group: "test-prop-group", Name: "test-property"})
@@ -762,7 +775,7 @@ func TestMultiNodeBroadcastWrite(t *testing.T) {
 	})
 	require.NoError(t, streamErr)
 	assertHasSchemas(t, stream2)
-	_, deleteErr := reg.DeleteStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
+	_, _, deleteErr := reg.DeleteStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
 	require.NoError(t, deleteErr)
 	_, getErr := reg.GetStream(ctx, &commonv1.Metadata{Group: "test-group", Name: testStreamName})
 	require.Error(t, getErr)
@@ -967,7 +980,8 @@ func TestOnAddOrUpdate_IgnoresNonMetaRoleNode(t *testing.T) {
 	reg.OnAddOrUpdate(schema.Metadata{Spec: noAddrNode})
 	assert.Len(t, reg.ActiveNodeNames(), 1, "meta node without address should not be registered")
 	// Registry should work via the initial meta node.
-	require.NoError(t, reg.CreateGroup(ctx, testGroup()))
+	_, createGroupErr0 := reg.CreateGroup(ctx, testGroup())
+	require.NoError(t, createGroupErr0)
 	got, getErr := reg.GetGroup(ctx, "test-group")
 	require.NoError(t, getErr)
 	assert.Equal(t, "test-group", got.GetMetadata().GetName())
@@ -1028,7 +1042,8 @@ func TestOnAddOrUpdate_RepairOldDataToNewNode(t *testing.T) {
 	handler := &testEventHandler{}
 	reg.RegisterHandler("test-handler", schema.KindGroup|schema.KindStream, handler)
 	ctx := context.Background()
-	require.NoError(t, reg.CreateGroup(ctx, testGroup()))
+	_, createGroupErr1 := reg.CreateGroup(ctx, testGroup())
+	require.NoError(t, createGroupErr1)
 	s := testStream()
 	_, createErr := reg.CreateStream(ctx, s)
 	require.NoError(t, createErr)
@@ -1069,7 +1084,8 @@ func TestOnDelete_RemoveNode(t *testing.T) {
 	require.NoError(t, regErr)
 	t.Cleanup(func() { _ = reg.Close() })
 	ctx := context.Background()
-	require.NoError(t, reg.CreateGroup(ctx, testGroup()))
+	_, createGroupErr2 := reg.CreateGroup(ctx, testGroup())
+	require.NoError(t, createGroupErr2)
 	s := testStream()
 	_, createErr := reg.CreateStream(ctx, s)
 	require.NoError(t, createErr)
@@ -1232,7 +1248,8 @@ func TestTLS_GroupCRUD(t *testing.T) {
 	reg := newTestRegistryTLS(t, addr)
 	ctx := context.Background()
 	g := testGroup()
-	require.NoError(t, reg.CreateGroup(ctx, g))
+	_, createGroupTLSErr := reg.CreateGroup(ctx, g)
+	require.NoError(t, createGroupTLSErr)
 	got, getErr := reg.GetGroup(ctx, "test-group")
 	require.NoError(t, getErr)
 	assert.Equal(t, "test-group", got.GetMetadata().GetName())
@@ -1240,12 +1257,13 @@ func TestTLS_GroupCRUD(t *testing.T) {
 	require.NoError(t, listErr)
 	assert.Len(t, groups, 1)
 	got.ResourceOpts.ShardNum = 4
-	require.NoError(t, reg.UpdateGroup(ctx, got))
+	_, updateGroupTLSErr := reg.UpdateGroup(ctx, got)
+	require.NoError(t, updateGroupTLSErr)
 	updated, getErr := reg.GetGroup(ctx, "test-group")
 	require.NoError(t, getErr)
 	assert.Equal(t, uint32(4), updated.GetResourceOpts().GetShardNum())
-	deleted, deleteErr := reg.DeleteGroup(ctx, "test-group")
-	require.NoError(t, deleteErr)
+	deleted, _, deleteGroupTLSErr := reg.DeleteGroup(ctx, "test-group")
+	require.NoError(t, deleteGroupTLSErr)
 	assert.True(t, deleted)
 	_, getErr = reg.GetGroup(ctx, "test-group")
 	require.Error(t, getErr)
diff --git a/banyand/metadata/schema/property/converter.go b/banyand/metadata/schema/property/converter.go
index af6987731..66326987c 100644
--- a/banyand/metadata/schema/property/converter.go
+++ b/banyand/metadata/schema/property/converter.go
@@ -41,6 +41,8 @@ const (
 	TagKeySource    = "source"
 	TagKeyKind      = "kind"
 	TagKeyUpdatedAt = "updated_at"
+	// TagKeyCreatedAt is the tag key for the first-appearance timestamp; survives updates unchanged.
+	TagKeyCreatedAt = "created_at"
 )
 
 // BuildPropertyID returns the property ID for a schema resource.
@@ -80,19 +82,29 @@ func SchemaToProperty(kind schema.Kind, spec proto.Message) (*propertyv1.Propert
 	if updatedAt != nil {
 		updatedAtNano = updatedAt.AsTime().UnixNano()
 	}
+	tags := []*modelv1.Tag{
+		{Key: TagKeyGroup, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: group}}}},
+		{Key: TagKeyName, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: name}}}},
+		{Key: TagKeySource, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: string(data)}}}},
+		{Key: TagKeyKind, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: kind.String()}}}},
+		{Key: TagKeyUpdatedAt, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: &modelv1.Int{Value: updatedAtNano}}}},
+	}
+	// Only persist the created_at tag when it has been stamped (non-zero).
+	if createdAt := getCreatedAtFromSpec(kind, spec); createdAt != nil {
+		if createdAtNano := createdAt.AsTime().UnixNano(); createdAtNano != 0 {
+			tags = append(tags, &modelv1.Tag{
+				Key:   TagKeyCreatedAt,
+				Value: &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: &modelv1.Int{Value: createdAtNano}}},
+			})
+		}
+	}
 	return &propertyv1.Property{
 		Metadata: &commonv1.Metadata{
 			Name:        kind.String(),
 			ModRevision: metadata.GetModRevision(),
 		},
-		Id: BuildPropertyID(kind, metadata),
-		Tags: []*modelv1.Tag{
-			{Key: TagKeyGroup, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: group}}}},
-			{Key: TagKeyName, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: name}}}},
-			{Key: TagKeySource, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: string(data)}}}},
-			{Key: TagKeyKind, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: kind.String()}}}},
-			{Key: TagKeyUpdatedAt, Value: &modelv1.TagValue{Value: &modelv1.TagValue_Int{Int: &modelv1.Int{Value: updatedAtNano}}}},
-		},
+		Id:        BuildPropertyID(kind, metadata),
+		Tags:      tags,
 		UpdatedAt: updatedAt,
 	}, nil
 }
@@ -144,6 +156,7 @@ type ParsedTags struct {
 	Source    string
 	Kind      string
 	UpdatedAt int64
+	CreatedAt int64
 }
 
 // ParseTags extracts all known tag values in a single pass.
@@ -161,6 +174,8 @@ func ParseTags(tags []*modelv1.Tag) ParsedTags {
 			pt.Kind = tag.GetValue().GetStr().GetValue()
 		case TagKeyUpdatedAt:
 			pt.UpdatedAt = tag.GetValue().GetInt().GetValue()
+		case TagKeyCreatedAt:
+			pt.CreatedAt = tag.GetValue().GetInt().GetValue()
 		}
 	}
 	return pt
@@ -276,8 +291,12 @@ func ParsePropID(propID string) (kind schema.Kind, group, name string, err error
 	return kind, rest[:slashIdx], rest[slashIdx+1:], nil
 }
 
-// buildDeleteRequest builds a DeleteSchema request.
-func buildDeleteRequest(kind schema.Kind, group, name string) *schemav1.DeleteSchemaRequest {
+// buildDeleteRequest builds a DeleteSchema request. The caller supplies deleteTime
+// (unix nanos) so the value persisted as the tombstone on the server matches the
+// value broadcastDelete returns to its caller — otherwise two separate time.Now()
+// snapshots drift, breaking the design contract that delete_time is the
+// authoritative tombstone timestamp.
+func buildDeleteRequest(kind schema.Kind, group, name string, deleteTime int64) *schemav1.DeleteSchemaRequest {
 	metadata := &commonv1.Metadata{Group: group, Name: name}
 	return &schemav1.DeleteSchemaRequest{
 		Delete: &propertyv1.DeleteRequest{
@@ -285,7 +304,7 @@ func buildDeleteRequest(kind schema.Kind, group, name string) *schemav1.DeleteSc
 			Name:  kind.String(),
 			Id:    BuildPropertyID(kind, metadata),
 		},
-		UpdateAt: timestamppb.New(time.Now()),
+		UpdateAt: timestamppb.New(time.Unix(0, deleteTime)),
 	}
 }
 
@@ -335,6 +354,87 @@ func getUpdatedAtFromSpec(kind schema.Kind, spec proto.Message) (*timestamppb.Ti
 	return timestamppb.Now(), nil
 }
 
+// getCreatedAtFromSpec returns the created_at timestamp from a schema spec, or nil if not present.
+func getCreatedAtFromSpec(kind schema.Kind, spec proto.Message) *timestamppb.Timestamp {
+	switch kind {
+	case schema.KindGroup:
+		if g, ok := spec.(*commonv1.Group); ok {
+			return g.GetCreatedAt()
+		}
+	case schema.KindStream:
+		if s, ok := spec.(*databasev1.Stream); ok {
+			return s.GetCreatedAt()
+		}
+	case schema.KindMeasure:
+		if m, ok := spec.(*databasev1.Measure); ok {
+			return m.GetCreatedAt()
+		}
+	case schema.KindTrace:
+		if t, ok := spec.(*databasev1.Trace); ok {
+			return t.GetCreatedAt()
+		}
+	case schema.KindIndexRule:
+		if ir, ok := spec.(*databasev1.IndexRule); ok {
+			return ir.GetCreatedAt()
+		}
+	case schema.KindIndexRuleBinding:
+		if irb, ok := spec.(*databasev1.IndexRuleBinding); ok {
+			return irb.GetCreatedAt()
+		}
+	case schema.KindTopNAggregation:
+		if t, ok := spec.(*databasev1.TopNAggregation); ok {
+			return t.GetCreatedAt()
+		}
+	case schema.KindProperty:
+		if p, ok := spec.(*databasev1.Property); ok {
+			return p.GetCreatedAt()
+		}
+	case schema.KindNode, schema.KindMask:
+		// Node and Mask do not have a CreatedAt field.
+	}
+	return nil
+}
+
+// setCreatedAtOnSpec sets the created_at timestamp on a schema spec in-place.
+func setCreatedAtOnSpec(kind schema.Kind, spec proto.Message, ts *timestamppb.Timestamp) {
+	switch kind {
+	case schema.KindGroup:
+		if g, ok := spec.(*commonv1.Group); ok {
+			g.CreatedAt = ts
+		}
+	case schema.KindStream:
+		if s, ok := spec.(*databasev1.Stream); ok {
+			s.CreatedAt = ts
+		}
+	case schema.KindMeasure:
+		if m, ok := spec.(*databasev1.Measure); ok {
+			m.CreatedAt = ts
+		}
+	case schema.KindTrace:
+		if t, ok := spec.(*databasev1.Trace); ok {
+			t.CreatedAt = ts
+		}
+	case schema.KindIndexRule:
+		if ir, ok := spec.(*databasev1.IndexRule); ok {
+			ir.CreatedAt = ts
+		}
+	case schema.KindIndexRuleBinding:
+		if irb, ok := spec.(*databasev1.IndexRuleBinding); ok {
+			irb.CreatedAt = ts
+		}
+	case schema.KindTopNAggregation:
+		if t, ok := spec.(*databasev1.TopNAggregation); ok {
+			t.CreatedAt = ts
+		}
+	case schema.KindProperty:
+		if p, ok := spec.(*databasev1.Property); ok {
+			p.CreatedAt = ts
+		}
+	case schema.KindNode, schema.KindMask:
+		// Node and Mask do not have a CreatedAt field.
+	}
+}
+
 func getMetadataFromSpec(kind schema.Kind, spec proto.Message) (*commonv1.Metadata, error) {
 	switch kind {
 	case schema.KindGroup:
diff --git a/banyand/metadata/schema/property/converter_test.go b/banyand/metadata/schema/property/converter_test.go
index 5c14d9832..c8ee3f650 100644
--- a/banyand/metadata/schema/property/converter_test.go
+++ b/banyand/metadata/schema/property/converter_test.go
@@ -444,11 +444,14 @@ func TestParsePropID(t *testing.T) {
 }
 
 func TestBuildDeleteRequest(t *testing.T) {
-	req := buildDeleteRequest(schema.KindMeasure, "g1", "m1")
+	deleteTime := int64(1_700_000_000_000_000_000)
+	req := buildDeleteRequest(schema.KindMeasure, "g1", "m1", deleteTime)
 	assert.Equal(t, schema.SchemaGroup, req.GetDelete().GetGroup())
 	assert.Equal(t, "measure", req.GetDelete().GetName())
 	assert.Equal(t, "measure_g1/m1", req.GetDelete().GetId())
 	assert.NotNil(t, req.GetUpdateAt())
+	assert.Equal(t, deleteTime, req.GetUpdateAt().AsTime().UnixNano(),
+		"UpdateAt must reflect the caller-supplied deleteTime so the persisted tombstone matches the returned value")
 }
 
 func TestNewMessageForKind_AllKinds(t *testing.T) {
diff --git a/banyand/metadata/schema/property/created_at_test.go b/banyand/metadata/schema/property/created_at_test.go
new file mode 100644
index 000000000..c2b2d3aeb
--- /dev/null
+++ b/banyand/metadata/schema/property/created_at_test.go
@@ -0,0 +1,123 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package property_test
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+)
+
+// TestCreate_StampsCreatedAt verifies that CreateMeasure stamps created_at equal to
+// updated_at when the caller does not supply a created_at value (Step 1.3 / A3).
+func TestCreate_StampsCreatedAt(t *testing.T) {
+	addr := startTestSchemaServer(t)
+	reg := newTestRegistry(t, addr)
+	ctx := context.Background()
+	createTestGroup(t, reg)
+
+	_, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+	require.NoError(t, createErr)
+
+	got, getErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+	require.NoError(t, getErr)
+	require.NotNil(t, got.GetCreatedAt(), "created_at must be set after CreateMeasure")
+	require.NotNil(t, got.GetUpdatedAt(), "updated_at must be set after CreateMeasure")
+	assert.Equal(t, got.GetUpdatedAt().AsTime().UnixNano(), got.GetCreatedAt().AsTime().UnixNano(),
+		"created_at must equal updated_at on initial create")
+}
+
+// TestUpdate_PreservesCreatedAt verifies that UpdateMeasure preserves the original
+// created_at while advancing updated_at (Step 1.3 / A3).
+func TestUpdate_PreservesCreatedAt(t *testing.T) {
+	addr := startTestSchemaServer(t)
+	reg := newTestRegistry(t, addr)
+	ctx := context.Background()
+	createTestGroup(t, reg)
+
+	_, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+	require.NoError(t, createErr)
+
+	initial, getInitialErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+	require.NoError(t, getInitialErr)
+	createdAt := initial.GetCreatedAt()
+	require.NotNil(t, createdAt, "created_at must be set after CreateMeasure")
+
+	// Advance time so the new updated_at is strictly greater than created_at.
+	time.Sleep(2 * time.Millisecond)
+
+	// Add a new field — a real change that must pass through the equality checker.
+	initial.Fields = append(initial.Fields, &databasev1.FieldSpec{
+		Name:              "count",
+		FieldType:         databasev1.FieldType_FIELD_TYPE_INT,
+		CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+		EncodingMethod:    databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+	})
+	_, updateErr := reg.UpdateMeasure(ctx, initial)
+	require.NoError(t, updateErr)
+
+	updated, getUpdatedErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+	require.NoError(t, getUpdatedErr)
+	require.NotNil(t, updated.GetCreatedAt(), "created_at must still be set after UpdateMeasure")
+	assert.Equal(t, createdAt.AsTime().UnixNano(), updated.GetCreatedAt().AsTime().UnixNano(),
+		"created_at must be unchanged by UpdateMeasure")
+	assert.Greater(t, updated.GetUpdatedAt().AsTime().UnixNano(), updated.GetCreatedAt().AsTime().UnixNano(),
+		"updated_at must advance beyond created_at after an update")
+}
+
+// TestValidateMeasureUpdate_EntityChangeFails verifies that UpdateMeasure rejects
+// an entity change and returns an error containing "entity", while leaving the
+// stored schema's ModRevision unchanged (Step 1.3 / validate).
+func TestValidateMeasureUpdate_EntityChangeFails(t *testing.T) {
+	addr := startTestSchemaServer(t)
+	reg := newTestRegistry(t, addr)
+	ctx := context.Background()
+	createTestGroup(t, reg)
+
+	_, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+	require.NoError(t, createErr)
+
+	got, getErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+	require.NoError(t, getErr)
+	originalModRev := got.GetMetadata().GetModRevision()
+
+	// Add "endpoint" to the tag family so validate.Measure accepts the new entity,
+	// but validateMeasureUpdate rejects it (entity definition changed).
+	got.TagFamilies[0].Tags = append(got.TagFamilies[0].Tags, &databasev1.TagSpec{
+		Name: "endpoint",
+		Type: databasev1.TagType_TAG_TYPE_STRING,
+	})
+	got.Entity = &databasev1.Entity{TagNames: []string{"service_name", "endpoint"}}
+	_, updateErr := reg.UpdateMeasure(ctx, got)
+	require.Error(t, updateErr, "UpdateMeasure must fail when entity changes")
+	assert.Contains(t, updateErr.Error(), "entity", "error must mention 'entity'")
+
+	// The stored schema must be completely unchanged.
+	after, getAfterErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+	require.NoError(t, getAfterErr)
+	assert.Equal(t, originalModRev, after.GetMetadata().GetModRevision(),
+		"ModRevision must be unchanged after a rejected entity-change update")
+	assert.Equal(t, []string{"service_name"}, after.GetEntity().GetTagNames(),
+		"entity must be unchanged after a rejected update")
+}
diff --git a/banyand/metadata/schema/property/tombstone_invariant_test.go b/banyand/metadata/schema/property/tombstone_invariant_test.go
new file mode 100644
index 000000000..7c755cc6c
--- /dev/null
+++ b/banyand/metadata/schema/property/tombstone_invariant_test.go
@@ -0,0 +1,103 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package property_test
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	propertyv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/property/v1"
+	schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema/property"
+)
+
+// TestCreate_AfterTombstone_Reject_WhenLeq verifies that CreateMeasure returns
+// codes.InvalidArgument when a tombstone exists with a delete_time that is greater
+// than or equal to the create's updated_at (Step 1.3 / A4).
+//
+// Strategy: inject a far-future delete_time via the raw schema management RPC
+// so that time.Now() is always before the tombstone, forcing the rejection.
+func TestCreate_AfterTombstone_Reject_WhenLeq(t *testing.T) {
+	addr := startTestSchemaServer(t)
+	reg := newTestRegistry(t, addr)
+	rawMgmt := rawMgmtClient(t, addr)
+	ctx := context.Background()
+	createTestGroup(t, reg)
+
+	_, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+	require.NoError(t, createErr)
+
+	// Delete the measure with a far-future UpdateAt so any realtime CreateMeasure
+	// (which stamps UpdatedAt = time.Now()) will be before the tombstone.
+	farFuture := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)
+	_, deleteSchemaErr := rawMgmt.DeleteSchema(ctx, &schemav1.DeleteSchemaRequest{
+		Delete: &propertyv1.DeleteRequest{
+			Group: schema.SchemaGroup,
+			Name:  schema.KindMeasure.String(),
+			Id:    property.BuildPropertyID(schema.KindMeasure, &commonv1.Metadata{Group: "test-group", Name: "test-measure"}),
+		},
+		UpdateAt: timestamppb.New(farFuture),
+	})
+	require.NoError(t, deleteSchemaErr)
+
+	// Attempt to re-create: CreateMeasure stamps UpdatedAt = time.Now() < farFuture → rejected.
+	_, recreateErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+	require.Error(t, recreateErr, "CreateMeasure must fail when updated_at <= tombstone delete_time")
+	st, ok := status.FromError(recreateErr)
+	require.True(t, ok, "error must be a gRPC status error")
+	assert.Equal(t, codes.InvalidArgument, st.Code())
+	assert.Contains(t, st.Message(), "updated_at_before_tombstone")
+}
+
+// TestCreate_AfterTombstone_Accept_WhenGreater verifies that re-creating a schema
+// after deletion succeeds when the new updated_at is after the tombstone's delete_time
+// (Step 1.3 / A4).
+func TestCreate_AfterTombstone_Accept_WhenGreater(t *testing.T) {
+	addr := startTestSchemaServer(t)
+	reg := newTestRegistry(t, addr)
+	ctx := context.Background()
+	createTestGroup(t, reg)
+
+	_, createErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+	require.NoError(t, createErr)
+
+	// Normal deletion — tombstone delete_time ≈ time.Now().
+	deleted, _, deleteErr := reg.DeleteMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+	require.NoError(t, deleteErr)
+	assert.True(t, deleted)
+
+	// Small gap ensures the new UpdatedAt (time.Now()) is strictly > deleteTime.
+	time.Sleep(2 * time.Millisecond)
+
+	// Re-create: CreateMeasure stamps UpdatedAt = time.Now() > deleteTime → accepted.
+	_, recreateErr := reg.CreateMeasure(ctx, testMeasure("test-group"))
+	require.NoError(t, recreateErr, "re-create after delete must succeed when updated_at > tombstone delete_time")
+
+	got, getErr := reg.GetMeasure(ctx, &commonv1.Metadata{Group: "test-group", Name: "test-measure"})
+	require.NoError(t, getErr)
+	assert.Equal(t, "test-measure", got.GetMetadata().GetName())
+}
diff --git a/banyand/metadata/schema/schema.go b/banyand/metadata/schema/schema.go
index e52088265..93a4430b2 100644
--- a/banyand/metadata/schema/schema.go
+++ b/banyand/metadata/schema/schema.go
@@ -119,25 +119,25 @@ type Stream interface {
 	ListStream(ctx context.Context, opt ListOpt) ([]*databasev1.Stream, error)
 	CreateStream(ctx context.Context, stream *databasev1.Stream) (int64, error)
 	UpdateStream(ctx context.Context, stream *databasev1.Stream) (int64, error)
-	DeleteStream(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+	DeleteStream(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
 }
 
 // IndexRule allows CRUD index rule schemas in a group.
 type IndexRule interface {
 	GetIndexRule(ctx context.Context, metadata *commonv1.Metadata) (*databasev1.IndexRule, error)
 	ListIndexRule(ctx context.Context, opt ListOpt) ([]*databasev1.IndexRule, error)
-	CreateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) error
-	UpdateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) error
-	DeleteIndexRule(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+	CreateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) (int64, error)
+	UpdateIndexRule(ctx context.Context, indexRule *databasev1.IndexRule) (int64, error)
+	DeleteIndexRule(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
 }
 
 // IndexRuleBinding allows CRUD index rule binding schemas in a group.
 type IndexRuleBinding interface {
 	GetIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (*databasev1.IndexRuleBinding, error)
 	ListIndexRuleBinding(ctx context.Context, opt ListOpt) ([]*databasev1.IndexRuleBinding, error)
-	CreateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) error
-	UpdateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) error
-	DeleteIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+	CreateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) (int64, error)
+	UpdateIndexRuleBinding(ctx context.Context, indexRuleBinding *databasev1.IndexRuleBinding) (int64, error)
+	DeleteIndexRuleBinding(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
 }
 
 // Measure allows CRUD measure schemas in a group.
@@ -148,7 +148,7 @@ type Measure interface {
 	ListMeasure(ctx context.Context, opt ListOpt) ([]*databasev1.Measure, error)
 	CreateMeasure(ctx context.Context, measure *databasev1.Measure) (int64, error)
 	UpdateMeasure(ctx context.Context, measure *databasev1.Measure) (int64, error)
-	DeleteMeasure(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+	DeleteMeasure(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
 	TopNAggregations(ctx context.Context, metadata *commonv1.Metadata) ([]*databasev1.TopNAggregation, error)
 }
 
@@ -160,7 +160,7 @@ type Trace interface {
 	ListTrace(ctx context.Context, opt ListOpt) ([]*databasev1.Trace, error)
 	CreateTrace(ctx context.Context, trace *databasev1.Trace) (int64, error)
 	UpdateTrace(ctx context.Context, trace *databasev1.Trace) (int64, error)
-	DeleteTrace(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+	DeleteTrace(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
 }
 
 // Group allows CRUD groups which is namespaces of resources.
@@ -170,9 +170,9 @@ type Group interface {
 	GetGroup(ctx context.Context, group string) (*commonv1.Group, error)
 	ListGroup(ctx context.Context) ([]*commonv1.Group, error)
 	// DeleteGroup delete all items belonging to the group
-	DeleteGroup(ctx context.Context, group string) (bool, error)
-	CreateGroup(ctx context.Context, group *commonv1.Group) error
-	UpdateGroup(ctx context.Context, group *commonv1.Group) error
+	DeleteGroup(ctx context.Context, group string) (bool, int64, error)
+	CreateGroup(ctx context.Context, group *commonv1.Group) (int64, error)
+	UpdateGroup(ctx context.Context, group *commonv1.Group) (int64, error)
 }
 
 // TopNAggregation allows CRUD top-n aggregation schemas in a group.
@@ -181,9 +181,9 @@ type Group interface {
 type TopNAggregation interface {
 	GetTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (*databasev1.TopNAggregation, error)
 	ListTopNAggregation(ctx context.Context, opt ListOpt) ([]*databasev1.TopNAggregation, error)
-	CreateTopNAggregation(ctx context.Context, measure *databasev1.TopNAggregation) error
-	UpdateTopNAggregation(ctx context.Context, measure *databasev1.TopNAggregation) error
-	DeleteTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+	CreateTopNAggregation(ctx context.Context, measure *databasev1.TopNAggregation) (int64, error)
+	UpdateTopNAggregation(ctx context.Context, measure *databasev1.TopNAggregation) (int64, error)
+	DeleteTopNAggregation(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
 }
 
 // Node allows CRUD node schemas in a group.
@@ -202,5 +202,5 @@ type Property interface {
 	ListProperty(ctx context.Context, opt ListOpt) ([]*databasev1.Property, error)
 	CreateProperty(ctx context.Context, property *databasev1.Property) error
 	UpdateProperty(ctx context.Context, property *databasev1.Property) error
-	DeleteProperty(ctx context.Context, metadata *commonv1.Metadata) (bool, error)
+	DeleteProperty(ctx context.Context, metadata *commonv1.Metadata) (bool, int64, error)
 }
diff --git a/banyand/metadata/schema/schemaserver/service.go b/banyand/metadata/schema/schemaserver/service.go
index 016985e4a..d720aa961 100644
--- a/banyand/metadata/schema/schemaserver/service.go
+++ b/banyand/metadata/schema/schemaserver/service.go
@@ -97,6 +97,7 @@ type server struct {
 	flushTimeout             time.Duration
 	snapshotSeq              uint64
 	expireTimeout            time.Duration
+	tombstoneRetention       time.Duration
 	repairQuickBuildTreeTime time.Duration
 	maxRecvMsgSize           run.Bytes
 	maxFileSnapshotNum       int
@@ -148,6 +149,8 @@ func (s *server) FlagSet() *run.FlagSet {
 	flagS.Uint32Var(&s.port, "schema-server-grpc-port", 17916, "the port of schema server")
 	flagS.DurationVar(&s.flushTimeout, "schema-server-flush-timeout", 5*time.Second, "memory flush interval")
 	flagS.DurationVar(&s.expireTimeout, "schema-server-expire-delete-timeout", time.Hour*24*7, "soft-delete expiration")
+	flagS.DurationVar(&s.tombstoneRetention, "schema-server-tombstone-retention", time.Hour*24*7,
+		"schema tombstone retention duration; tombstones older than this value are physically excluded from queries")
 	flagS.BoolVar(&s.tls, "schema-server-tls", false, "connection uses TLS if true")
 	flagS.StringVar(&s.certFile, "schema-server-cert-file", "", "the TLS cert file")
 	flagS.StringVar(&s.keyFile, "schema-server-key-file", "", "the TLS key file")
@@ -169,6 +172,10 @@ func (s *server) Validate() error {
 	if s.port == 0 {
 		s.port = 17920
 	}
+	// Short retention values (<1h) are unusual in production but required by integration tests
+	// that verify the physical-removal path of tombstone GC. Accept the value as given so
+	// operators (and the test harness) retain control; silent clamping previously hid the
+	// §4.7.2 test case.
 	s.addr = net.JoinHostPort(s.host, strconv.FormatUint(uint64(s.port), 10))
 	if s.addr == ":" {
 		return errors.New("no address")
@@ -221,11 +228,15 @@ func (s *server) PreRun(_ context.Context) error {
 	s.snapshotDir = filepath.Join(s.root, snapshot.SchemaPropertyCatalogName, "snapshots")
 	repairDir := filepath.Join(s.root, snapshot.SchemaPropertyCatalogName, "repair")
 
+	expireToDelete := s.expireTimeout
+	if s.tombstoneRetention > 0 {
+		expireToDelete = s.tombstoneRetention
+	}
 	cfg := db.Config{
 		Location:               dataDir,
 		MetricsScopeName:       "schema_property",
 		FlushInterval:          s.flushTimeout,
-		ExpireToDeleteDuration: s.expireTimeout,
+		ExpireToDeleteDuration: expireToDelete,
 		Repair: db.RepairConfig{
 			Enabled:            true,
 			Location:           repairDir,
diff --git a/banyand/property/db/shard.go b/banyand/property/db/shard.go
index 6e8410eac..a0482c8f7 100644
--- a/banyand/property/db/shard.go
+++ b/banyand/property/db/shard.go
@@ -429,6 +429,12 @@ func (s *shard) prepareForMerge(src []*roaring.Bitmap, segments []segment.Segmen
 		return src, nil
 	}
 	for segID, seg := range segments {
+		// bluge's drop-set entries point at segment-owned bitmaps that concurrent searches
+		// read via Snapshot.PostingsIterator. Mutating the original aliases would race against
+		// roaring.AndNot in those readers. We take a private copy at most once per segment —
+		// only on the first expired doc — so segments with no expirations cost nothing and
+		// segments with many expirations clone exactly once instead of per-docID.
+		privateOwned := false
 		var docID uint64
 		for ; docID < seg.Count(); docID++ {
 			var deleteTime int64
@@ -446,8 +452,13 @@ func (s *shard) prepareForMerge(src []*roaring.Bitmap, segments []segment.Segmen
 				continue
 			}
 
-			if src[segID] == nil {
-				src[segID] = roaring.New()
+			if !privateOwned {
+				if src[segID] == nil {
+					src[segID] = roaring.New()
+				} else {
+					src[segID] = src[segID].Clone()
+				}
+				privateOwned = true
 			}
 
 			src[segID].Add(uint32(docID))
diff --git a/banyand/property/gossip/trace.go b/banyand/property/gossip/trace.go
index 627eeae30..93780864a 100644
--- a/banyand/property/gossip/trace.go
+++ b/banyand/property/gossip/trace.go
@@ -170,7 +170,8 @@ func (s *service) initInternalTraceGroup(ctx context.Context) error {
 			},
 		},
 	}
-	return s.metadata.GroupRegistry().CreateGroup(ctx, g)
+	_, createErr := s.metadata.GroupRegistry().CreateGroup(ctx, g)
+	return createErr
 }
 
 func (s *service) savingTracingSpans() (err error) {
diff --git a/banyand/stream/metadata.go b/banyand/stream/metadata.go
index 269104023..a2e452044 100644
--- a/banyand/stream/metadata.go
+++ b/banyand/stream/metadata.go
@@ -204,10 +204,12 @@ func (sr *schemaRepo) OnDelete(metadata schema.Metadata) {
 			Metadata: g,
 		})
 	case schema.KindStream:
+		streamSpec := metadata.Spec.(*databasev1.Stream)
 		sr.SendMetadataEvent(resourceSchema.MetadataEvent{
-			Typ:      resourceSchema.EventDelete,
-			Kind:     resourceSchema.EventKindResource,
-			Metadata: metadata.Spec.(*databasev1.Stream),
+			Typ:            resourceSchema.EventDelete,
+			Kind:           resourceSchema.EventKindResource,
+			DeleteRevision: streamSpec.GetMetadata().GetModRevision(),
+			Metadata:       streamSpec,
 		})
 	case schema.KindIndexRuleBinding:
 		if binding, ok := metadata.Spec.(*databasev1.IndexRuleBinding); ok {
diff --git a/banyand/stream/metadata_test.go b/banyand/stream/metadata_test.go
index d5f3e96c5..a0f0fc1b6 100644
--- a/banyand/stream/metadata_test.go
+++ b/banyand/stream/metadata_test.go
@@ -59,7 +59,7 @@ var _ = Describe("Metadata", func() {
 
 	Context("Manage group", func() {
 		It("should close the group", func() {
-			deleted, err := svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), "default")
+			deleted, _, err := svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), "default")
 			Expect(err).ShouldNot(HaveOccurred())
 			Expect(deleted).Should(BeTrue())
 			Eventually(func() bool {
@@ -74,7 +74,8 @@ var _ = Describe("Metadata", func() {
 			Expect(groupSchema).ShouldNot(BeNil())
 			groupSchema.ResourceOpts.ShardNum = 4
 
-			Expect(svcs.metadataService.GroupRegistry().UpdateGroup(context.TODO(), groupSchema)).Should(Succeed())
+			_, updateGroupErr := svcs.metadataService.GroupRegistry().UpdateGroup(context.TODO(), groupSchema)
+			Expect(updateGroupErr).ShouldNot(HaveOccurred())
 
 			Eventually(func() bool {
 				group, ok := svcs.stream.LoadGroup("default")
@@ -97,7 +98,7 @@ var _ = Describe("Metadata", func() {
 			}).WithTimeout(flags.EventuallyTimeout).Should(BeTrue())
 		})
 		It("should close the stream", func() {
-			deleted, err := svcs.metadataService.StreamRegistry().DeleteStream(context.TODO(), &commonv1.Metadata{
+			deleted, _, err := svcs.metadataService.StreamRegistry().DeleteStream(context.TODO(), &commonv1.Metadata{
 				Name:  "sw",
 				Group: "default",
 			})
@@ -354,7 +355,7 @@ var _ = Describe("Schema Change", func() {
 
 		groupCounter++
 		groupName = "test-schema-change-" + strconv.Itoa(groupCounter)
-		err := svcs.metadataService.GroupRegistry().CreateGroup(context.TODO(), &commonv1.Group{
+		_, err := svcs.metadataService.GroupRegistry().CreateGroup(context.TODO(), &commonv1.Group{
 			Metadata: &commonv1.Metadata{
 				Name: groupName,
 			},
@@ -379,7 +380,7 @@ var _ = Describe("Schema Change", func() {
 	})
 
 	AfterEach(func() {
-		_, _ = svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), groupName)
+		_, _, _ = svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), groupName)
 		deferFn()
 		Eventually(gleak.Goroutines, flags.EventuallyTimeout).ShouldNot(gleak.HaveLeaked(goods))
 	})
@@ -679,7 +680,7 @@ func setupSchemaChangeStream(svcs *services, streamName, groupName string, opts
 
 	return &schemaChangeEnv{
 		cleanup: func() {
-			_, _ = svcs.metadataService.StreamRegistry().DeleteStream(ctx, &commonv1.Metadata{
+			_, _, _ = svcs.metadataService.StreamRegistry().DeleteStream(ctx, &commonv1.Metadata{
 				Name:  streamName,
 				Group: groupName,
 			})
diff --git a/banyand/trace/metadata.go b/banyand/trace/metadata.go
index 3e0249e4c..8c3322f35 100644
--- a/banyand/trace/metadata.go
+++ b/banyand/trace/metadata.go
@@ -213,10 +213,12 @@ func (sr *schemaRepo) OnDelete(metadata schema.Metadata) {
 			Metadata: g,
 		})
 	case schema.KindTrace:
+		traceSpec := metadata.Spec.(*databasev1.Trace)
 		sr.SendMetadataEvent(resourceSchema.MetadataEvent{
-			Typ:      resourceSchema.EventDelete,
-			Kind:     resourceSchema.EventKindResource,
-			Metadata: metadata.Spec.(*databasev1.Trace),
+			Typ:            resourceSchema.EventDelete,
+			Kind:           resourceSchema.EventKindResource,
+			DeleteRevision: traceSpec.GetMetadata().GetModRevision(),
+			Metadata:       traceSpec,
 		})
 	case schema.KindIndexRuleBinding:
 		if binding, ok := metadata.Spec.(*databasev1.IndexRuleBinding); ok {
diff --git a/banyand/trace/metadata_test.go b/banyand/trace/metadata_test.go
index d01f774c7..e605d5de5 100644
--- a/banyand/trace/metadata_test.go
+++ b/banyand/trace/metadata_test.go
@@ -64,7 +64,7 @@ var _ = Describe("Metadata", func() {
 		BeforeEach(func() {
 			groupCounter++
 			groupName = "test-schema-change-" + strconv.Itoa(groupCounter)
-			err := svcs.metadataService.GroupRegistry().CreateGroup(context.TODO(), &commonv1.Group{
+			_, err := svcs.metadataService.GroupRegistry().CreateGroup(context.TODO(), &commonv1.Group{
 				Metadata: &commonv1.Metadata{
 					Name: groupName,
 				},
@@ -89,7 +89,7 @@ var _ = Describe("Metadata", func() {
 		})
 
 		AfterEach(func() {
-			_, _ = svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), groupName)
+			_, _, _ = svcs.metadataService.GroupRegistry().DeleteGroup(context.TODO(), groupName)
 		})
 
 		It("deleting reserved tag should fail", func() {
@@ -496,8 +496,8 @@ func setupSchemaChangeTrace(svcs *services, traceName, groupName string, opts tr
 		Tags: []string{"duration"},
 		Type: databasev1.IndexRule_TYPE_TREE,
 	}
-	err = svcs.metadataService.IndexRuleRegistry().CreateIndexRule(ctx, indexRule)
-	Expect(err).ShouldNot(HaveOccurred())
+	_, createIRErr := svcs.metadataService.IndexRuleRegistry().CreateIndexRule(ctx, indexRule)
+	Expect(createIRErr).ShouldNot(HaveOccurred())
 
 	// Create index rule binding
 	indexRuleBindingName := traceName + "-index-rule-binding"
@@ -514,8 +514,8 @@ func setupSchemaChangeTrace(svcs *services, traceName, groupName string, opts tr
 		BeginAt:  timestamppb.Now(),
 		ExpireAt: timestamppb.New(time.Now().Add(24 * time.Hour)),
 	}
-	err = svcs.metadataService.IndexRuleBindingRegistry().CreateIndexRuleBinding(ctx, indexRuleBinding)
-	Expect(err).ShouldNot(HaveOccurred())
+	_, createIRBErr := svcs.metadataService.IndexRuleBindingRegistry().CreateIndexRuleBinding(ctx, indexRuleBinding)
+	Expect(createIRBErr).ShouldNot(HaveOccurred())
 
 	Eventually(func() bool {
 		_, err := svcs.trace.Trace(&commonv1.Metadata{
@@ -527,15 +527,15 @@ func setupSchemaChangeTrace(svcs *services, traceName, groupName string, opts tr
 
 	return &schemaChangeEnv{
 		cleanup: func() {
-			_, _ = svcs.metadataService.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, &commonv1.Metadata{
+			_, _, _ = svcs.metadataService.IndexRuleBindingRegistry().DeleteIndexRuleBinding(ctx, &commonv1.Metadata{
 				Name:  indexRuleBindingName,
 				Group: groupName,
 			})
-			_, _ = svcs.metadataService.IndexRuleRegistry().DeleteIndexRule(ctx, &commonv1.Metadata{
+			_, _, _ = svcs.metadataService.IndexRuleRegistry().DeleteIndexRule(ctx, &commonv1.Metadata{
 				Name:  indexRuleName,
 				Group: groupName,
 			})
-			_, _ = svcs.metadataService.TraceRegistry().DeleteTrace(ctx, &commonv1.Metadata{
+			_, _, _ = svcs.metadataService.TraceRegistry().DeleteTrace(ctx, &commonv1.Metadata{
 				Name:  traceName,
 				Group: groupName,
 			})
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 44696cff7..eea4435c6 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -37,6 +37,9 @@
     - [LogicalExpression.LogicalOp](#banyandb-model-v1-LogicalExpression-LogicalOp)
     - [Sort](#banyandb-model-v1-Sort)
   
+- [banyandb/model/v1/write.proto](#banyandb_model_v1_write-proto)
+    - [Status](#banyandb-model-v1-Status)
+  
 - [banyandb/measure/v1/query.proto](#banyandb_measure_v1_query-proto)
     - [DataPoint](#banyandb-measure-v1-DataPoint)
     - [DataPoint.Field](#banyandb-measure-v1-DataPoint-Field)
@@ -47,8 +50,10 @@
     - [QueryRequest.Aggregation](#banyandb-measure-v1-QueryRequest-Aggregation)
     - [QueryRequest.FieldProjection](#banyandb-measure-v1-QueryRequest-FieldProjection)
     - [QueryRequest.GroupBy](#banyandb-measure-v1-QueryRequest-GroupBy)
+    - [QueryRequest.GroupModRevisionsEntry](#banyandb-measure-v1-QueryRequest-GroupModRevisionsEntry)
     - [QueryRequest.Top](#banyandb-measure-v1-QueryRequest-Top)
     - [QueryResponse](#banyandb-measure-v1-QueryResponse)
+    - [QueryResponse.GroupStatusesEntry](#banyandb-measure-v1-QueryResponse-GroupStatusesEntry)
   
 - [banyandb/measure/v1/topn.proto](#banyandb_measure_v1_topn-proto)
     - [TopNList](#banyandb-measure-v1-TopNList)
@@ -90,13 +95,17 @@
 - [banyandb/stream/v1/query.proto](#banyandb_stream_v1_query-proto)
     - [Element](#banyandb-stream-v1-Element)
     - [QueryRequest](#banyandb-stream-v1-QueryRequest)
+    - [QueryRequest.GroupModRevisionsEntry](#banyandb-stream-v1-QueryRequest-GroupModRevisionsEntry)
     - [QueryResponse](#banyandb-stream-v1-QueryResponse)
+    - [QueryResponse.GroupStatusesEntry](#banyandb-stream-v1-QueryResponse-GroupStatusesEntry)
   
 - [banyandb/trace/v1/query.proto](#banyandb_trace_v1_query-proto)
     - [InternalQueryResponse](#banyandb-trace-v1-InternalQueryResponse)
     - [InternalTrace](#banyandb-trace-v1-InternalTrace)
     - [QueryRequest](#banyandb-trace-v1-QueryRequest)
+    - [QueryRequest.GroupModRevisionsEntry](#banyandb-trace-v1-QueryRequest-GroupModRevisionsEntry)
     - [QueryResponse](#banyandb-trace-v1-QueryResponse)
+    - [QueryResponse.GroupStatusesEntry](#banyandb-trace-v1-QueryResponse-GroupStatusesEntry)
     - [Span](#banyandb-trace-v1-Span)
     - [Trace](#banyandb-trace-v1-Trace)
   
@@ -107,8 +116,28 @@
 - [banyandb/bydbql/v1/rpc.proto](#banyandb_bydbql_v1_rpc-proto)
     - [BydbQLService](#banyandb-bydbql-v1-BydbQLService)
   
-- [banyandb/model/v1/write.proto](#banyandb_model_v1_write-proto)
-    - [Status](#banyandb-model-v1-Status)
+- [banyandb/schema/v1/barrier.proto](#banyandb_schema_v1_barrier-proto)
+    - [AwaitRevisionAppliedRequest](#banyandb-schema-v1-AwaitRevisionAppliedRequest)
+    - [AwaitRevisionAppliedResponse](#banyandb-schema-v1-AwaitRevisionAppliedResponse)
+    - [AwaitSchemaAppliedRequest](#banyandb-schema-v1-AwaitSchemaAppliedRequest)
+    - [AwaitSchemaAppliedResponse](#banyandb-schema-v1-AwaitSchemaAppliedResponse)
+    - [AwaitSchemaDeletedRequest](#banyandb-schema-v1-AwaitSchemaDeletedRequest)
+    - [AwaitSchemaDeletedResponse](#banyandb-schema-v1-AwaitSchemaDeletedResponse)
+    - [NodeLaggard](#banyandb-schema-v1-NodeLaggard)
+    - [SchemaKey](#banyandb-schema-v1-SchemaKey)
+  
+    - [SchemaBarrierService](#banyandb-schema-v1-SchemaBarrierService)
+  
+- [banyandb/cluster/v1/node_schema_status.proto](#banyandb_cluster_v1_node_schema_status-proto)
+    - [GetAbsentKeysRequest](#banyandb-cluster-v1-GetAbsentKeysRequest)
+    - [GetAbsentKeysResponse](#banyandb-cluster-v1-GetAbsentKeysResponse)
+    - [GetKeyRevisionsRequest](#banyandb-cluster-v1-GetKeyRevisionsRequest)
+    - [GetKeyRevisionsResponse](#banyandb-cluster-v1-GetKeyRevisionsResponse)
+    - [GetMaxRevisionRequest](#banyandb-cluster-v1-GetMaxRevisionRequest)
+    - [GetMaxRevisionResponse](#banyandb-cluster-v1-GetMaxRevisionResponse)
+    - [KeyRevision](#banyandb-cluster-v1-KeyRevision)
+  
+    - [NodeSchemaStatusService](#banyandb-cluster-v1-NodeSchemaStatusService)
   
 - [banyandb/cluster/v1/rpc.proto](#banyandb_cluster_v1_rpc-proto)
     - [FileInfo](#banyandb-cluster-v1-FileInfo)
@@ -899,6 +928,43 @@ Each item in a string array is seen as a token instead of a query expression.
 
 
 
+<a name="banyandb_model_v1_write-proto"></a>
+<p align="right"><a href="#top">Top</a></p>
+
+## banyandb/model/v1/write.proto
+
+
+ 
+
+
+<a name="banyandb-model-v1-Status"></a>
+
+### Status
+Status is the response status for write
+
+| Name | Number | Description |
+| ---- | ------ | ----------- |
+| STATUS_UNSPECIFIED | 0 |  |
+| STATUS_SUCCEED | 1 |  |
+| STATUS_INVALID_TIMESTAMP | 2 |  |
+| STATUS_NOT_FOUND | 3 |  |
+| STATUS_EXPIRED_SCHEMA | 4 |  |
+| STATUS_INTERNAL_ERROR | 5 |  |
+| STATUS_DISK_FULL | 6 |  |
+| STATUS_VERSION_UNSUPPORTED | 7 | Client version not supported |
+| STATUS_VERSION_DEPRECATED | 8 | Client version deprecated but still supported |
+| STATUS_METADATA_REQUIRED | 9 | Metadata is required for the first request |
+| STATUS_SCHEMA_NOT_APPLIED | 10 | Client&#39;s ModRevision is ahead of the server cache; the server waited and timed out. |
+
+
+ 
+
+ 
+
+ 
+
+
+
 <a name="banyandb_measure_v1_query-proto"></a>
 <p align="right"><a href="#top">Top</a></p>
 
@@ -1015,6 +1081,7 @@ QueryRequest is the request contract for query.
 | trace | [bool](#bool) |  | trace is used to enable trace for the query |
 | stages | [string](#string) | repeated | stages is used to specify the stage of the data points in the lifecycle |
 | rewrite_agg_top_n_result | [bool](#bool) |  | rewrite_agg_top_n_result will rewrite agg result to raw data |
+| group_mod_revisions | [QueryRequest.GroupModRevisionsEntry](#banyandb-measure-v1-QueryRequest-GroupModRevisionsEntry) | repeated | group_mod_revisions gates the query per group. Keys match entries in `groups`; values are the client&#39;s known mod_revision for that group. Empty map or value 0 means &#34;don&#39;t gate&#34;. A group not listed in the map is not gated. |
 
 
 
@@ -1068,6 +1135,22 @@ QueryRequest is the request contract for query.
 
 
 
+<a name="banyandb-measure-v1-QueryRequest-GroupModRevisionsEntry"></a>
+
+### QueryRequest.GroupModRevisionsEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) |  |  |
+| value | [int64](#int64) |  |  |
+
+
+
+
+
+
 <a name="banyandb-measure-v1-QueryRequest-Top"></a>
 
 ### QueryRequest.Top
@@ -1095,6 +1178,23 @@ QueryResponse is the response for a query to the Query module.
 | ----- | ---- | ----- | ----------- |
 | data_points | [DataPoint](#banyandb-measure-v1-DataPoint) | repeated | data_points are the actual data returned |
 | trace | [banyandb.common.v1.Trace](#banyandb-common-v1-Trace) |  | trace contains the trace information of the query when trace is enabled |
+| group_statuses | [QueryResponse.GroupStatusesEntry](#banyandb-measure-v1-QueryResponse-GroupStatusesEntry) | repeated | group_statuses reports the per-group gate outcome. Populated even when the query short-circuits because any group failed the gate. |
+
+
+
+
+
+
+<a name="banyandb-measure-v1-QueryResponse-GroupStatusesEntry"></a>
+
+### QueryResponse.GroupStatusesEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) |  |  |
+| value | [banyandb.model.v1.Status](#banyandb-model-v1-Status) |  |  |
 
 
 
@@ -1218,6 +1318,7 @@ Group is an internal object for Group management
 | catalog | [Catalog](#banyandb-common-v1-Catalog) |  | catalog denotes which type of data the group contains |
 | resource_opts | [ResourceOpts](#banyandb-common-v1-ResourceOpts) |  | resourceOpts indicates the structure of the underlying kv storage |
 | updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | updated_at indicates when resources of the group are updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | created_at is the first-appearance timestamp; survives updates unchanged. |
 
 
 
@@ -1652,6 +1753,23 @@ QueryRequest is the request contract for query.
 | projection | [banyandb.model.v1.TagProjection](#banyandb-model-v1-TagProjection) |  | projection can be used to select the key names of the element in the response |
 | trace | [bool](#bool) |  | trace is used to enable trace for the query |
 | stages | [string](#string) | repeated | stage is used to specify the stage of the query in the lifecycle |
+| group_mod_revisions | [QueryRequest.GroupModRevisionsEntry](#banyandb-stream-v1-QueryRequest-GroupModRevisionsEntry) | repeated | group_mod_revisions gates the query per group. Keys match entries in `groups`; values are the client&#39;s known mod_revision for that group. Empty map or value 0 means &#34;don&#39;t gate&#34;. A group not listed in the map is not gated. |
+
+
+
+
+
+
+<a name="banyandb-stream-v1-QueryRequest-GroupModRevisionsEntry"></a>
+
+### QueryRequest.GroupModRevisionsEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) |  |  |
+| value | [int64](#int64) |  |  |
 
 
 
@@ -1668,6 +1786,23 @@ QueryResponse is the response for a query to the Query module.
 | ----- | ---- | ----- | ----------- |
 | elements | [Element](#banyandb-stream-v1-Element) | repeated | elements are the actual data returned |
 | trace | [banyandb.common.v1.Trace](#banyandb-common-v1-Trace) |  | trace contains the trace information of the query when trace is enabled |
+| group_statuses | [QueryResponse.GroupStatusesEntry](#banyandb-stream-v1-QueryResponse-GroupStatusesEntry) | repeated | group_statuses reports the per-group gate outcome. Populated even when the query short-circuits because any group failed the gate. |
+
+
+
+
+
+
+<a name="banyandb-stream-v1-QueryResponse-GroupStatusesEntry"></a>
+
+### QueryResponse.GroupStatusesEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) |  |  |
+| value | [banyandb.model.v1.Status](#banyandb-model-v1-Status) |  |  |
 
 
 
@@ -1741,6 +1876,23 @@ QueryRequest is the request contract for query.
 | tag_projection | [string](#string) | repeated | projection can be used to select the names of the tags in the response |
 | trace | [bool](#bool) |  | trace is used to enable trace for the query |
 | stages | [string](#string) | repeated | stage is used to specify the stage of the query in the lifecycle |
+| group_mod_revisions | [QueryRequest.GroupModRevisionsEntry](#banyandb-trace-v1-QueryRequest-GroupModRevisionsEntry) | repeated | group_mod_revisions gates the query per group. Keys match entries in `groups`; values are the client&#39;s known mod_revision for that group. Empty map or value 0 means &#34;don&#39;t gate&#34;. A group not listed in the map is not gated. |
+
+
+
+
+
+
+<a name="banyandb-trace-v1-QueryRequest-GroupModRevisionsEntry"></a>
+
+### QueryRequest.GroupModRevisionsEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) |  |  |
+| value | [int64](#int64) |  |  |
 
 
 
@@ -1757,6 +1909,23 @@ QueryResponse is the response of a query.
 | ----- | ---- | ----- | ----------- |
 | traces | [Trace](#banyandb-trace-v1-Trace) | repeated | traces is a list of traces that match the query, with spans grouped by trace ID. |
 | trace_query_result | [banyandb.common.v1.Trace](#banyandb-common-v1-Trace) |  | trace_query_result contains the trace of the query execution if tracing is enabled. |
+| group_statuses | [QueryResponse.GroupStatusesEntry](#banyandb-trace-v1-QueryResponse-GroupStatusesEntry) | repeated | group_statuses reports the per-group gate outcome. Populated even when the query short-circuits because any group failed the gate. |
+
+
+
+
+
+
+<a name="banyandb-trace-v1-QueryResponse-GroupStatusesEntry"></a>
+
+### QueryResponse.GroupStatusesEntry
+
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [string](#string) |  |  |
+| value | [banyandb.model.v1.Status](#banyandb-model-v1-Status) |  |  |
 
 
 
@@ -1881,32 +2050,293 @@ BydbQLService provides query interface for BanyanDB Query Language (BydbQL)
 
 
 
-<a name="banyandb_model_v1_write-proto"></a>
+<a name="banyandb_schema_v1_barrier-proto"></a>
 <p align="right"><a href="#top">Top</a></p>
 
-## banyandb/model/v1/write.proto
+## banyandb/schema/v1/barrier.proto
+
+
+
+<a name="banyandb-schema-v1-AwaitRevisionAppliedRequest"></a>
+
+### AwaitRevisionAppliedRequest
+AwaitRevisionAppliedRequest carries the minimum mod_revision the caller is
+waiting for and a wall-clock budget for the wait.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| min_revision | [int64](#int64) |  |  |
+| timeout | [google.protobuf.Duration](#google-protobuf-Duration) |  |  |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitRevisionAppliedResponse"></a>
+
+### AwaitRevisionAppliedResponse
+AwaitRevisionAppliedResponse reports whether every node reached the target
+revision before the timeout, and lists laggards otherwise.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| applied | [bool](#bool) |  |  |
+| laggards | [NodeLaggard](#banyandb-schema-v1-NodeLaggard) | repeated |  |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitSchemaAppliedRequest"></a>
+
+### AwaitSchemaAppliedRequest
+AwaitSchemaAppliedRequest pairs each key with a per-key minimum revision.
+keys is capped at 10000 server-side; exceeding the cap returns
+InvalidArgument. A min_revisions entry of 0 means &#34;just present, any
+revision&#34;.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| keys | [SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated |  |
+| min_revisions | [int64](#int64) | repeated |  |
+| timeout | [google.protobuf.Duration](#google-protobuf-Duration) |  |  |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitSchemaAppliedResponse"></a>
+
+### AwaitSchemaAppliedResponse
+AwaitSchemaAppliedResponse reports whether every requested key is present at
+or above its target revision on every node.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| applied | [bool](#bool) |  |  |
+| laggards | [NodeLaggard](#banyandb-schema-v1-NodeLaggard) | repeated |  |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitSchemaDeletedRequest"></a>
+
+### AwaitSchemaDeletedRequest
+AwaitSchemaDeletedRequest names the keys that must disappear from every
+node&#39;s cache before the call returns.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| keys | [SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated |  |
+| timeout | [google.protobuf.Duration](#google-protobuf-Duration) |  |  |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-AwaitSchemaDeletedResponse"></a>
+
+### AwaitSchemaDeletedResponse
+AwaitSchemaDeletedResponse reports whether every node has removed the
+requested keys.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| applied | [bool](#bool) |  |  |
+| laggards | [NodeLaggard](#banyandb-schema-v1-NodeLaggard) | repeated |  |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-NodeLaggard"></a>
+
+### NodeLaggard
+NodeLaggard reports a single data node that has not caught up to the
+requested schema state. missing_keys is populated by AwaitSchemaApplied
+responses; still_present_keys is populated by AwaitSchemaDeleted responses.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| node | [string](#string) |  |  |
+| current_mod_revision | [int64](#int64) |  |  |
+| missing_keys | [SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated |  |
+| still_present_keys | [SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated |  |
+
+
+
+
+
+
+<a name="banyandb-schema-v1-SchemaKey"></a>
+
+### SchemaKey
+SchemaKey identifies a schema resource by kind and name. Valid kind values:
+&#34;measure&#34;, &#34;stream&#34;, &#34;trace&#34;, &#34;property&#34;, &#34;index_rule&#34;,
+&#34;index_rule_binding&#34;, &#34;group&#34;, &#34;top_n_aggregation&#34;.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| kind | [string](#string) |  |  |
+| group | [string](#string) |  |  |
+| name | [string](#string) |  |  |
+
+
+
 
 
  
 
+ 
+
+ 
+
+
+<a name="banyandb-schema-v1-SchemaBarrierService"></a>
+
+### SchemaBarrierService
+SchemaBarrierService lets clients block until every data node in the cluster
+has caught up to a target schema state. The standalone implementation lands
+in Step 1.8; the distributed liaison fan-out lands in Steps 1.15-1.17.
+
+| Method Name | Request Type | Response Type | Description |
+| ----------- | ------------ | ------------- | ------------|
+| AwaitRevisionApplied | [AwaitRevisionAppliedRequest](#banyandb-schema-v1-AwaitRevisionAppliedRequest) | [AwaitRevisionAppliedResponse](#banyandb-schema-v1-AwaitRevisionAppliedResponse) | AwaitRevisionApplied blocks until every data node&#39;s local schema cache has observed mod_revision &gt;= min_revision, or the timeout elapses. |
+| AwaitSchemaApplied | [AwaitSchemaAppliedRequest](#banyandb-schema-v1-AwaitSchemaAppliedRequest) | [AwaitSchemaAppliedResponse](#banyandb-schema-v1-AwaitSchemaAppliedResponse) | AwaitSchemaApplied blocks until every data node reports all requested keys present at or above the per-key mod_revision. |
+| AwaitSchemaDeleted | [AwaitSchemaDeletedRequest](#banyandb-schema-v1-AwaitSchemaDeletedRequest) | [AwaitSchemaDeletedResponse](#banyandb-schema-v1-AwaitSchemaDeletedResponse) | AwaitSchemaDeleted blocks until every data node has removed all requested keys from its cache. |
+
+ 
+
+
+
+<a name="banyandb_cluster_v1_node_schema_status-proto"></a>
+<p align="right"><a href="#top">Top</a></p>
+
+## banyandb/cluster/v1/node_schema_status.proto
+
+
+
+<a name="banyandb-cluster-v1-GetAbsentKeysRequest"></a>
+
+### GetAbsentKeysRequest
+GetAbsentKeysRequest names the keys whose absence the caller wants to
+confirm.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| keys | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated |  |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetAbsentKeysResponse"></a>
+
+### GetAbsentKeysResponse
+GetAbsentKeysResponse partitions the requested keys into absent and
+still-present subsets.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| absent_keys | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated |  |
+| still_present_keys | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated |  |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetKeyRevisionsRequest"></a>
+
+### GetKeyRevisionsRequest
+GetKeyRevisionsRequest names the keys whose per-key revisions the caller
+wants to inspect.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| keys | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) | repeated |  |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetKeyRevisionsResponse"></a>
+
+### GetKeyRevisionsResponse
+GetKeyRevisionsResponse lists per-key revisions in the same order the
+caller supplied keys.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| revisions | [KeyRevision](#banyandb-cluster-v1-KeyRevision) | repeated |  |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetMaxRevisionRequest"></a>
+
+### GetMaxRevisionRequest
+GetMaxRevisionRequest carries no parameters; the node returns its current
+max mod_revision.
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-GetMaxRevisionResponse"></a>
+
+### GetMaxRevisionResponse
+GetMaxRevisionResponse holds the node&#39;s current max mod_revision.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| max_mod_revision | [int64](#int64) |  |  |
+
+
+
+
+
+
+<a name="banyandb-cluster-v1-KeyRevision"></a>
+
+### KeyRevision
+KeyRevision pairs a SchemaKey with the node&#39;s local mod_revision and
+presence flag.
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| key | [banyandb.schema.v1.SchemaKey](#banyandb-schema-v1-SchemaKey) |  |  |
+| mod_revision | [int64](#int64) |  |  |
+| present | [bool](#bool) |  |  |
 
-<a name="banyandb-model-v1-Status"></a>
 
-### Status
-Status is the response status for write
 
-| Name | Number | Description |
-| ---- | ------ | ----------- |
-| STATUS_UNSPECIFIED | 0 |  |
-| STATUS_SUCCEED | 1 |  |
-| STATUS_INVALID_TIMESTAMP | 2 |  |
-| STATUS_NOT_FOUND | 3 |  |
-| STATUS_EXPIRED_SCHEMA | 4 |  |
-| STATUS_INTERNAL_ERROR | 5 |  |
-| STATUS_DISK_FULL | 6 |  |
-| STATUS_VERSION_UNSUPPORTED | 7 | Client version not supported |
-| STATUS_VERSION_DEPRECATED | 8 | Client version deprecated but still supported |
-| STATUS_METADATA_REQUIRED | 9 | Metadata is required for the first request |
 
 
  
@@ -1916,6 +2346,22 @@ Status is the response status for write
  
 
 
+<a name="banyandb-cluster-v1-NodeSchemaStatusService"></a>
+
+### NodeSchemaStatusService
+NodeSchemaStatusService is exposed by every data node so the liaison can
+inspect each node&#39;s local schema cache when satisfying a SchemaBarrierService
+request. The implementation lands in Step 2.1.
+
+| Method Name | Request Type | Response Type | Description |
+| ----------- | ------------ | ------------- | ------------|
+| GetMaxRevision | [GetMaxRevisionRequest](#banyandb-cluster-v1-GetMaxRevisionRequest) | [GetMaxRevisionResponse](#banyandb-cluster-v1-GetMaxRevisionResponse) | GetMaxRevision returns the highest mod_revision currently observed by the node&#39;s schema cache. |
+| GetKeyRevisions | [GetKeyRevisionsRequest](#banyandb-cluster-v1-GetKeyRevisionsRequest) | [GetKeyRevisionsResponse](#banyandb-cluster-v1-GetKeyRevisionsResponse) | GetKeyRevisions returns the per-key mod_revision observed by the node, and a presence flag for each key. |
+| GetAbsentKeys | [GetAbsentKeysRequest](#banyandb-cluster-v1-GetAbsentKeysRequest) | [GetAbsentKeysResponse](#banyandb-cluster-v1-GetAbsentKeysResponse) | GetAbsentKeys partitions the requested keys into those the node has already removed and those that are still present. |
+
+ 
+
+
 
 <a name="banyandb_cluster_v1_rpc-proto"></a>
 <p align="right"><a href="#top">Top</a></p>
@@ -2450,6 +2896,7 @@ IndexRule should bind to a subject through an IndexRuleBinding to generate prope
 | updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | updated_at indicates when the IndexRule is updated |
 | analyzer | [string](#string) |  | analyzer analyzes tag value to support the full-text searching for TYPE_INVERTED indices. available analyzers are: - &#34;standard&#34; provides grammar based tokenization - &#34;simple&#34; breaks text into tokens at any non-letter character, such as numbers, spaces, hyphens and apostrophes, discards non-letter characters, and changes uppercase to lowercase. - &#34;keyword&#34; is a “noop” analyzer which returns the entire input string as a single tok [...]
 | no_sort | [bool](#bool) |  | no_sort indicates whether the index is not for sorting. |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | created_at is the first-appearance timestamp; survives updates unchanged. |
 
 
 
@@ -2472,6 +2919,7 @@ to control how to generate time series indices.
 | begin_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | begin_at_nanoseconds is the timestamp, after which the binding will be active |
 | expire_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | expire_at_nanoseconds it the timestamp, after which the binding will be inactive expire_at_nanoseconds must be larger than begin_at_nanoseconds |
 | updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | updated_at indicates when the IndexRuleBinding is updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | created_at is the first-appearance timestamp; survives updates unchanged. |
 
 
 
@@ -2494,6 +2942,7 @@ Measure intends to store data point
 | updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | updated_at indicates when the measure is updated |
 | index_mode | [bool](#bool) |  | index_mode specifies whether the data should be stored exclusively in the index, meaning it will not be stored in the data storage system. |
 | sharding_key | [ShardingKey](#banyandb-database-v1-ShardingKey) |  | sharding_key determines the distribution of TopN-related data. |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | created_at is the first-appearance timestamp; survives updates unchanged. |
 
 
 
@@ -2511,6 +2960,7 @@ Property stores the user defined data
 | metadata | [banyandb.common.v1.Metadata](#banyandb-common-v1-Metadata) |  | metadata is the identity of a property |
 | tags | [TagSpec](#banyandb-database-v1-TagSpec) | repeated | tag stores the content of a property |
 | updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | updated_at indicates when the property is updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | created_at is the first-appearance timestamp; survives updates unchanged. |
 
 
 
@@ -2544,6 +2994,7 @@ Stream intends to store streaming data, for example, traces or logs
 | tag_families | [TagFamilySpec](#banyandb-database-v1-TagFamilySpec) | repeated | tag_families |
 | entity | [Entity](#banyandb-database-v1-Entity) |  | entity indicates how to generate a series and shard a stream |
 | updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | updated_at indicates when the stream is updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | created_at is the first-appearance timestamp; survives updates unchanged. |
 
 
 
@@ -2615,6 +3066,7 @@ TopNAggregation generates offline TopN statistics for a measure&#39;s TopN appro
 | counters_number | [int32](#int32) |  | counters_number sets the number of counters to be tracked. The default value is 1000 |
 | lru_size | [int32](#int32) |  | lru_size defines how much entry is allowed to be maintained in the memory |
 | updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | updated_at indicates when the measure is updated |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | created_at is the first-appearance timestamp; survives updates unchanged. |
 
 
 
@@ -2638,6 +3090,7 @@ while the group of a Trace corresponds to a physical directory.
 | timestamp_tag_name | [string](#string) |  | timestamp_tag_name is the name of the tag that stores the timestamp. |
 | updated_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | updated_at indicates when the trace resource is updated. |
 | span_id_tag_name | [string](#string) |  | span_id_tag_name is the name of the tag that stores the span ID. |
+| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) |  | created_at is the first-appearance timestamp; survives updates unchanged. |
 
 
 
@@ -2904,6 +3357,11 @@ GroupDeletionTask represents the status of a group deletion operation.
 
 
 
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
 
 
 
@@ -2935,6 +3393,8 @@ GroupRegistryServiceDeleteResponse is the response for deleting a group.
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | schema_info | [SchemaInfo](#banyandb-database-v1-SchemaInfo) |  | schema_info contains the schema resources that would be deleted (populated in dry-run mode). |
+| delete_time | [int64](#int64) |  | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
 
 
 
@@ -3110,6 +3570,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
 
 
 
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
 
 
 
@@ -3135,6 +3600,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
 
 
 
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
 
 
 
@@ -3163,6 +3633,8 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | deleted | [bool](#bool) |  |  |
+| delete_time | [int64](#int64) |  | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
 
 
 
@@ -3281,6 +3753,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
 
 
 
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
 
 
 
@@ -3306,6 +3783,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
 
 
 
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
 
 
 
@@ -3334,6 +3816,8 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | deleted | [bool](#bool) |  |  |
+| delete_time | [int64](#int64) |  | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
 
 
 
@@ -3452,6 +3936,11 @@ GroupRegistryServiceQueryResponse is the response for querying a group deletion
 
 
 
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
 
 
 
@@ -3545,6 +4034,8 @@ LiaisonInfo contains information about pending operations in liaison.
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | deleted | [bool](#bool) |  |  |
+| delete_time | [int64](#int64) |  | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
 
 
 
@@ -3726,6 +4217,8 @@ LiaisonInfo contains information about pending operations in liaison.
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | deleted | [bool](#bool) |  |  |
+| delete_time | [int64](#int64) |  | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
 
 
 
@@ -4081,6 +4574,8 @@ ShardInfo contains information about a specific shard.
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | deleted | [bool](#bool) |  |  |
+| delete_time | [int64](#int64) |  | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
 
 
 
@@ -4229,6 +4724,11 @@ ShardInfo contains information about a specific shard.
 
 
 
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
 
 
 
@@ -4257,6 +4757,8 @@ ShardInfo contains information about a specific shard.
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | deleted | [bool](#bool) |  |  |
+| delete_time | [int64](#int64) |  | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
 
 
 
@@ -4375,6 +4877,11 @@ ShardInfo contains information about a specific shard.
 
 
 
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision assigned by the server on successful create/update. |
+
+
 
 
 
@@ -4433,6 +4940,8 @@ ShardInfo contains information about a specific shard.
 | Field | Type | Label | Description |
 | ----- | ---- | ----- | ----------- |
 | deleted | [bool](#bool) |  |  |
+| delete_time | [int64](#int64) |  | delete_time is the server-assigned tombstone timestamp in unix nanos. |
+| mod_revision | [int64](#int64) |  | mod_revision is the etcd revision of the tombstone; zero if the server did not record one. |
 
 
 
diff --git a/docs/interacting/schema-consistency/barriers.md b/docs/interacting/schema-consistency/barriers.md
new file mode 100644
index 000000000..13fc1cbbb
--- /dev/null
+++ b/docs/interacting/schema-consistency/barriers.md
@@ -0,0 +1,194 @@
+# Schema Barrier RPCs
+
+`SchemaBarrierService` (proto package `banyandb.schema.v1`) lets a client block synchronously until the cluster's schema state matches a target. All three RPCs share the same shape: a request carrying the target plus a timeout, and a response carrying `bool applied` and a list of `NodeLaggard` entries when the call did not converge in time.
+
+This page is a reference. For end-to-end workflows that combine barriers with the write and query gates, see [Scenarios](scenarios.md).
+
+## SchemaKey
+
+Several requests reference schemas by `SchemaKey`:
+
+```protobuf
+message SchemaKey {
+  string kind  = 1;  // "measure", "stream", "trace", "property",
+                     // "index_rule", "index_rule_binding", "group", "top_n_aggregation"
+  string group = 2;  // group name; empty for kind="group"
+  string name  = 3;  // resource name; empty for kind="group" if referring to the group itself
+}
+```
+
+For a Group key, set `kind = "group"`, `name = "<group-name>"`, `group = ""`.
+
+## NodeLaggard
+
+When `applied == false`, the response carries one entry per data node that has not converged:
+
+```protobuf
+message NodeLaggard {
+  string             node                  = 1;
+  int64              current_mod_revision  = 2;  // populated by AwaitRevisionApplied
+  repeated SchemaKey missing_keys          = 3;  // populated by AwaitSchemaApplied
+  repeated SchemaKey still_present_keys    = 4;  // populated by AwaitSchemaDeleted
+}
+```
+
+In standalone mode there is one `NodeLaggard` entry at most. In cluster mode (Phase 2) the list reports every laggard node.
+
+## AwaitRevisionApplied
+
+Block until every data node's cache observes `mod_revision >= min_revision`.
+
+```protobuf
+rpc AwaitRevisionApplied(AwaitRevisionAppliedRequest)
+    returns (AwaitRevisionAppliedResponse);
+
+message AwaitRevisionAppliedRequest {
+  int64                     min_revision = 1;
+  google.protobuf.Duration  timeout      = 2;
+}
+
+message AwaitRevisionAppliedResponse {
+  bool                  applied  = 1;
+  repeated NodeLaggard  laggards = 2;
+}
+```
+
+### Semantics
+
+- The server returns as soon as the watermark reaches `min_revision`. Polling is internal; the client gets one synchronous answer.
+- On timeout the response has `applied = false` and lists every laggard node with its `current_mod_revision`. The call **never** returns indefinitely.
+- A `min_revision` of `0` returns `applied = true` immediately.
+- A nil or zero `timeout` falls back to a server default (5 s in the standalone implementation).
+
+### Go example
+
+```go
+import (
+    "context"
+    "time"
+
+    schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+    "google.golang.org/protobuf/types/known/durationpb"
+)
+
+resp, err := barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+    MinRevision: targetRev,
+    Timeout:     durationpb.New(10 * time.Second),
+})
+if err != nil {
+    return err  // gRPC-level error; not a timeout
+}
+if !resp.GetApplied() {
+    for _, l := range resp.GetLaggards() {
+        log.Printf("node %s at rev %d, target %d", l.GetNode(), l.GetCurrentModRevision(), targetRev)
+    }
+    return fmt.Errorf("schema did not propagate within timeout")
+}
+```
+
+## AwaitSchemaApplied
+
+Block until each requested key is present at or above its target revision on every node.
+
+```protobuf
+rpc AwaitSchemaApplied(AwaitSchemaAppliedRequest)
+    returns (AwaitSchemaAppliedResponse);
+
+message AwaitSchemaAppliedRequest {
+  repeated SchemaKey         keys          = 1;
+  repeated int64             min_revisions = 2;
+  google.protobuf.Duration   timeout       = 3;
+}
+
+message AwaitSchemaAppliedResponse {
+  bool                  applied  = 1;
+  repeated NodeLaggard  laggards = 2;
+}
+```
+
+### Semantics
+
+- `keys` and `min_revisions` are **parallel arrays**: `min_revisions[i]` applies to `keys[i]`. If `min_revisions` is empty or shorter than `keys`, the missing entries default to `0` (meaning "any revision; just be present").
+- `keys` is capped at **10 000** server-side. Exceeding the cap returns `InvalidArgument`. Chunk larger sets across multiple calls; merge `applied` results with logical AND and union laggards.
+- On timeout, each laggard's `missing_keys` enumerates exactly the keys not yet at the target on that node.
+- `applied = true` is returned only when every key on every node is at or above the target.
+
+### Go example
+
+```go
+keys := []*schemav1.SchemaKey{
+    {Kind: "measure", Group: "sw_metric", Name: "service_cpm_minute"},
+    {Kind: "measure", Group: "sw_metric", Name: "service_resp_time"},
+}
+revs := []int64{firstRev, secondRev}
+
+resp, err := barrier.AwaitSchemaApplied(ctx, &schemav1.AwaitSchemaAppliedRequest{
+    Keys:         keys,
+    MinRevisions: revs,
+    Timeout:      durationpb.New(15 * time.Second),
+})
+if err != nil { return err }
+if !resp.GetApplied() {
+    for _, l := range resp.GetLaggards() {
+        for _, k := range l.GetMissingKeys() {
+            log.Printf("node %s missing %s/%s/%s", l.GetNode(), k.GetKind(), k.GetGroup(), k.GetName())
+        }
+    }
+}
+```
+
+## AwaitSchemaDeleted
+
+Block until each named key is **absent** from every node's cache.
+
+```protobuf
+rpc AwaitSchemaDeleted(AwaitSchemaDeletedRequest)
+    returns (AwaitSchemaDeletedResponse);
+
+message AwaitSchemaDeletedRequest {
+  repeated SchemaKey         keys    = 1;
+  google.protobuf.Duration   timeout = 2;
+}
+
+message AwaitSchemaDeletedResponse {
+  bool                  applied  = 1;
+  repeated NodeLaggard  laggards = 2;
+}
+```
+
+### Semantics
+
+- Same 10 000-key cap as `AwaitSchemaApplied`.
+- On timeout each laggard's `still_present_keys` enumerates the keys the call was waiting to see disappear.
+- This RPC reports tombstone propagation, not tombstone GC. A key that has tombstoned is "absent" for the purpose of this call even before the tombstone is physically removed by the GC loop.
+
+### Go example
+
+```go
+resp, err := barrier.AwaitSchemaDeleted(ctx, &schemav1.AwaitSchemaDeletedRequest{
+    Keys: []*schemav1.SchemaKey{
+        {Kind: "measure", Group: "sw_metric", Name: "service_cpm_minute"},
+    },
+    Timeout: durationpb.New(10 * time.Second),
+})
+if err != nil { return err }
+if !resp.GetApplied() {
+    // Inspect resp.Laggards[i].StillPresentKeys
+}
+```
+
+## Choosing the right barrier
+
+| Situation | RPC |
+|---|---|
+| You just got a `mod_revision = R` back from a Create/Update and want every node to see R or later | `AwaitRevisionApplied(R)` |
+| You created several schemas at once and need to confirm each one is visible | `AwaitSchemaApplied(keys, revs)` |
+| You want to confirm a deleted schema is gone before re-creating | `AwaitSchemaDeleted(keys)` |
+| You don't know the revisions but want presence | `AwaitSchemaApplied(keys, []int64{})` (zero-filled) |
+
+## Common pitfalls
+
+- **Setting an absurdly small timeout.** Even on a healthy cluster, schema propagation has measurable latency. Set timeouts in the seconds range, not milliseconds. The barrier is a synchronous safety net; treat it like one.
+- **Forgetting that `min_revisions` is a parallel array.** If you pass three keys but only two revisions, the third key is gated on revision `0` (any revision). That is rarely what the caller meant.
+- **Calling the barrier from the same context that issued the schema mutation, with a context deadline tighter than the timeout.** The context cancellation wins. Either propagate enough deadline budget or use a fresh context for the barrier.
+- **Treating a non-error response with `applied = false` as an error.** It is a soft outcome — the client may want to act on the laggards (alert, fall back to the un-gated path) rather than abort.
diff --git a/docs/interacting/schema-consistency/overview.md b/docs/interacting/schema-consistency/overview.md
new file mode 100644
index 000000000..8586f118c
--- /dev/null
+++ b/docs/interacting/schema-consistency/overview.md
@@ -0,0 +1,68 @@
+# Schema Consistency Overview
+
+BanyanDB is an eventually-consistent system. Schema mutations (Create / Update / Delete) are accepted by the schema server, persisted, and then propagated to every data node's in-memory cache asynchronously. Most clients tolerate this lag, but request paths that depend on a freshly-changed schema — read-after-write, delete-then-recreate, multi-stage rollouts — need a way to **observe** propagation and **gate** their requests on it.
+
+Phase 1 (shipped in [v0.11.0](../../../CHANGES.md)) adds the client-observable surface for that.
+
+## What is new in v0.11.0
+
+The new client surface falls into three groups.
+
+### 1. Response fields — clients read them
+
+| Field | Where | What it tells the client |
+|---|---|---|
+| `mod_revision` (`int64`) | `*ServiceCreateResponse`, `*ServiceUpdateResponse` for Group / IndexRule / IndexRuleBinding / TopNAggregation | The schema's revision after the call. Use this in subsequent gated writes/queries. |
+| `delete_time` (`int64`, Unix ns) | every `*ServiceDeleteResponse` | The tombstone's persisted delete time. Required for tombstone-aware re-create. |
+| `created_at` (`google.protobuf.Timestamp`) | Stream / Measure / Trace / Property / IndexRule / IndexRuleBinding / TopNAggregation / Group | The first-creation timestamp; preserved across updates. Drives the time-range clamp. |
+| `group_statuses` (`map<string, Status>`) | Stream / Measure / Trace `QueryResponse` | Per-group outcome of the query gate. |
+
+### 2. Request fields — clients send them
+
+| Field | Where | What it does |
+|---|---|---|
+| `Metadata.mod_revision` (`int64`) | `WriteRequest` for Stream / Measure | Gates the write against the cached schema revision. `0` skips the gate. |
+| `group_mod_revisions` (`map<string, int64>`) | Stream / Measure / Trace `QueryRequest` | Per-group gate. Empty map skips, `0` for a group skips that group. |
+
+### 3. Barrier RPCs — clients block on propagation
+
+`SchemaBarrierService` (proto: `banyandb.schema.v1.SchemaBarrierService`) exposes three RPCs:
+
+| RPC | Use it to |
+|---|---|
+| `AwaitRevisionApplied(min_revision, timeout)` | Wait until every data node's cache observes `mod_revision >= min_revision`. |
+| `AwaitSchemaApplied(keys, min_revisions, timeout)` | Wait until each named schema key is present at the requested revision on every node. |
+| `AwaitSchemaDeleted(keys, timeout)` | Wait until each named schema key is absent from every node. |
+
+See [Barrier RPCs](barriers.md) for the full reference.
+
+## Status codes that may surface to clients
+
+| Status | Code | Meaning |
+|---|---|---|
+| `STATUS_SUCCEED` | 1 | Gate passed. Continue. |
+| `STATUS_EXPIRED_SCHEMA` | 7 | Client's `mod_revision` is **behind** the cache. Client is using a stale schema view; refresh and retry. |
+| `STATUS_SCHEMA_NOT_APPLIED` | 10 | Client's `mod_revision` is **ahead** of the cache; the schema mutation has not yet propagated. Call `AwaitRevisionApplied` (or wait briefly) and retry. |
+
+## Backward compatibility
+
+Every gate is opt-in. A client that does not set `Metadata.mod_revision` (or sends `0`) and does not populate `QueryRequest.group_mod_revisions` (or sends `0` per group) sees exactly the prior behavior — no rejection, no clamp, no waiting.
+
+The new response fields are additive on the wire; older clients that ignore unknown fields keep working unchanged. The only enum addition is `STATUS_SCHEMA_NOT_APPLIED = 10`. Strict-unmarshal clients that reject unknown enum values must be upgraded before deploying against a v0.11.0 server. See the migration appendix in [Scenarios](scenarios.md#migration-appendix) for details.
+
+## When you need these tools
+
+- You write a schema and immediately read or write data against it → gate the data write or query, or block on `AwaitSchemaApplied`.
+- You delete a schema and recreate the same key → use `delete_time` and `AwaitSchemaDeleted` to confirm tombstone propagation, then satisfy the re-create invariant.
+- You drive a fan-out workflow (dashboards, batch pipelines) that joins multiple groups with independent versioning → use `group_mod_revisions` per group.
+- You bootstrap a large schema set and want to know when every node has caught up → call `AwaitRevisionApplied` once with the maximum revision.
+
+When none of those apply (single-group, schema is created long before the writes/queries it gates), you can ignore the entire surface.
+
+## Related documents
+
+- [Barrier RPCs](barriers.md) — full reference for the three `SchemaBarrierService` calls
+- [Revision Gates](revision-gates.md) — write/query gate semantics, time-range clamp, tombstone invariant
+- [Scenarios](scenarios.md) — six worked workflows that combine the tools
+- [API Reference](../../api-reference.md) — generated proto reference
+- [Data Lifecycle](../data-lifecycle.md) — TTL-based data expiry (separate from schema GC)
diff --git a/docs/interacting/schema-consistency/revision-gates.md b/docs/interacting/schema-consistency/revision-gates.md
new file mode 100644
index 000000000..af0057faa
--- /dev/null
+++ b/docs/interacting/schema-consistency/revision-gates.md
@@ -0,0 +1,141 @@
+# Revision Gates
+
+The barrier RPCs let clients **block** on propagation. The revision gates let clients **send a write or query that fails fast** when the schema view does not align. Both surfaces use the same `mod_revision` value the server returned on the originating Create/Update.
+
+This page describes the three places a revision matters at request time:
+
+1. The **write-path gate** (`Metadata.mod_revision` on `WriteRequest`)
+2. The **query-path gate** (`group_mod_revisions` on `QueryRequest`)
+3. The **time-range clamp** driven by `created_at`
+
+It also covers the **tombstone invariant** that gates `Create` after a delete.
+
+## Write-path gate
+
+Stream and Measure write RPCs read `Metadata.mod_revision` on each `WriteRequest` and compare it against the cached schema revision for that group/name.
+
+| Client `mod_revision` | Cache state | Server response status |
+|---|---|---|
+| `0` | any | `STATUS_SUCCEED` — gate skipped |
+| `== cache.ModRevision` | match | `STATUS_SUCCEED` — write proceeds |
+| `< cache.ModRevision` | client behind | `STATUS_EXPIRED_SCHEMA` — fail fast, refresh schema, retry |
+| `> cache.ModRevision` | client ahead | held until `WaitForSchema` budget elapses, then `STATUS_SCHEMA_NOT_APPLIED` |
+
+The "ahead" branch lets the server absorb a brief propagation lag without surfacing a transient error to the client. The hold budget is server-configurable; the default is small (sub-second) so a write either commits quickly or returns promptly with `STATUS_SCHEMA_NOT_APPLIED`.
+
+### When to use it
+
+- A client wrote a schema update, then writes a data point that depends on the update having applied. Pass the new `mod_revision` to the write so the server can confirm it landed against the right schema.
+- A client retries an idempotent write across a schema change. If the schema advances during retry, the gate flags the stale request rather than silently writing against the wrong shape.
+
+### Sample
+
+```go
+import (
+    commonv1  "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+    measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+)
+
+writeClient.Send(&measurev1.WriteRequest{
+    Metadata: &commonv1.Metadata{
+        Group:       "sw_metric",
+        Name:        "service_cpm_minute",
+        ModRevision: knownRev,  // the value returned by Update / Get
+    },
+    DataPoint: dp,
+    MessageId: nextMessageID(),
+})
+```
+
+## Query-path gate
+
+Stream, Measure, and Trace `QueryRequest` messages accept a `map<string, int64> group_mod_revisions` (field 50). Each entry maps a group from the `groups` list to the `mod_revision` the client last observed for that group. The matching `QueryResponse.group_statuses` map (field 50) reports the gate outcome per group.
+
+| Map state | Server behavior |
+|---|---|
+| Empty map | Gate skipped for the entire query — backward-compatible default. |
+| Group present, value `0` | Gate skipped for that group. |
+| Group present, value matches cache | `group_statuses[group] = STATUS_SUCCEED` |
+| Group present, value below cache | `group_statuses[group] = STATUS_EXPIRED_SCHEMA`; query short-circuits, `elements` is empty |
+| Group present, value above cache | held until the wait budget; on expiry `STATUS_SCHEMA_NOT_APPLIED` |
+| Group absent from map (but present in `groups`) | Gate skipped for that group; partial-coverage opt-in |
+
+The per-group structure exists because multi-group queries should not be forced to take `max(rev_a, rev_b)` and block on the slowest group; each group is gated independently. See [Scenarios](scenarios.md#5-multi-group-cross-cutting-query) for a worked example.
+
+### Sample
+
+```go
+import streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+
+resp, err := streamClient.Query(ctx, &streamv1.QueryRequest{
+    Groups: []string{"sw_metric", "sw_metric_minute"},
+    Name:   "service_resp_time",
+    GroupModRevisions: map[string]int64{
+        "sw_metric":        rev1,
+        "sw_metric_minute": rev2,
+    },
+    TimeRange:  trange,
+    Projection: proj,
+})
+if err != nil { return err }
+
+for group, status := range resp.GetGroupStatuses() {
+    if status != modelv1.Status_STATUS_SUCCEED {
+        log.Printf("group %s gate: %s", group, status)
+    }
+}
+```
+
+## Time-range clamp
+
+Queries whose `time_range.start` falls before a schema's `created_at` are silently clamped:
+
+```
+effective_start = max(request.time_range.start, schema.created_at)
+```
+
+For a multi-group query, the server uses the **maximum** `created_at` across the queried groups, so the result set never reaches into a window before any of the participating schemas existed.
+
+| Range relative to `created_at` | Result |
+|---|---|
+| `time_range.end < created_at` | Empty result set, no error |
+| `time_range.start < created_at <= time_range.end` | Window is clipped; only data on or after `created_at` is returned |
+| `time_range.start >= created_at` | Untouched; full window honored |
+| `created_at == nil` (schema predates v0.11.0) | No-op; clamp does not fire |
+
+The clamp is **opt-in** by virtue of `group_mod_revisions`. If a query does not specify any per-group revision, the server does not invoke the clamp at all — pre-existing queries that do not yet know about the gate continue to behave as before. This protects bydbctl and other tools that may issue queries without schema awareness.
+
+### When the clamp matters
+
+- A schema was deleted and recreated with the same name (delete-then-recreate). A query that asks for the previous month must not return data from before the new schema's `created_at`. The clamp guarantees this without requiring the client to know about the delete.
+- A new measure was added today. A dashboard that asks for "last 7 days" gets data from today onward; no fabricated zeroes for the six days before the schema existed.
+
+## Tombstone invariant for re-create
+
+When a schema is deleted, the server retains a tombstone with `delete_time` (Unix nanoseconds). A subsequent `Create` for the same key is rejected with gRPC status `codes.InvalidArgument` and message `"updated_at_before_tombstone"` if:
+
+```
+incoming_schema.updated_at <= tombstone.delete_time
+```
+
+This prevents a replayed or stale `Create` from silently overwriting a more-recent delete.
+
+### How to satisfy it
+
+Client code should derive `UpdatedAt` from the wall clock at submission time. As long as the recreate is genuinely subsequent to the delete, the invariant holds. If a client batches operations, it must ensure timestamps advance — for example by re-stamping the spec right before the `Create` rather than reusing a captured value from earlier.
+
+### How to recover
+
+If the server returns `InvalidArgument: updated_at_before_tombstone`:
+
+1. Re-stamp the spec's `UpdatedAt` to `time.Now()` (or any value strictly greater than the tombstone's `delete_time`).
+2. Re-issue the Create.
+
+The error indicates a clock or batching issue; it is not retried automatically because retrying with the same payload would loop indefinitely.
+
+## Pitfalls
+
+- **Threading `mod_revision` through retries.** A retry that captured the original `mod_revision` may find the schema has advanced. Re-fetch the spec (or the most recent `mod_revision` from a registry sync) on each retry rather than reusing a stale value.
+- **Mixing gated and ungated traffic.** A workflow that gates writes but issues ungated queries (`group_mod_revisions` empty) loses the clamp. If the workflow depends on the clamp for correctness, it must populate per-group revisions even when it does not care about the gate's three-way split.
+- **Treating `STATUS_EXPIRED_SCHEMA` and `STATUS_SCHEMA_NOT_APPLIED` as the same.** They are opposites: expired ⇒ refresh schema, not-applied ⇒ wait or call `AwaitRevisionApplied`. Lumping them into a single error path masks real misconfiguration.
+- **Forgetting that the clamp is opt-in.** Tools such as bydbctl issue queries without `group_mod_revisions`. Their results are not clamped; an integration test that depends on the clamp must populate the map.
diff --git a/docs/interacting/schema-consistency/scenarios.md b/docs/interacting/schema-consistency/scenarios.md
new file mode 100644
index 000000000..a26b18ff1
--- /dev/null
+++ b/docs/interacting/schema-consistency/scenarios.md
@@ -0,0 +1,278 @@
+# Scenarios
+
+Six worked workflows that combine the new tools. Code samples are Go pseudocode against the BanyanDB Go client; adapt the calls to your language by following the proto definitions.
+
+The scenarios assume the following short-form imports:
+
+```go
+import (
+    commonv1   "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+    databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+    measurev1  "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+    modelv1    "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+    schemav1   "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+    "google.golang.org/protobuf/types/known/durationpb"
+)
+```
+
+---
+
+## 1. Read-your-writes after a fresh Create
+
+**Problem:** A test or migration creates a measure and immediately writes data points. Without the gate, a brief propagation lag can cause the write to land before the data node has loaded the schema.
+
+**Tools:** Create response → `mod_revision` → `AwaitRevisionApplied` → gated write.
+
+```go
+createResp, err := measureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+    Measure: spec,
+})
+if err != nil { return err }
+rev := createResp.GetModRevision()  // non-zero in v0.11.0+
+
+if _, err := barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+    MinRevision: rev,
+    Timeout:     durationpb.New(10 * time.Second),
+}); err != nil { return err }
+
+writeClient.Send(&measurev1.WriteRequest{
+    Metadata:  &commonv1.Metadata{Group: spec.Metadata.Group, Name: spec.Metadata.Name, ModRevision: rev},
+    DataPoint: dp,
+    MessageId: nextMessageID(),
+})
+```
+
+**Why both barrier and gate?** The barrier is a synchronous "schema is here" check; the gate on the write protects against a race where the cache evicts and reloads the schema between the barrier and the write.
+
+---
+
+## 2. Schema update with dependent writes
+
+**Problem:** A service updates a measure's tag list and then writes data points that use the new tag. Until propagation completes, writes that include the new tag should be held or rejected, not silently dropped.
+
+**Tools:** Update response → new `mod_revision` → `AwaitRevisionApplied` → gated writes with retry on `STATUS_SCHEMA_NOT_APPLIED`.
+
+```go
+updateResp, err := measureRegClient.Update(ctx, &databasev1.MeasureRegistryServiceUpdateRequest{
+    Measure: updatedSpec,
+})
+if err != nil { return err }
+newRev := updateResp.GetModRevision()
+
+// Best-effort barrier; the gate is the source of truth.
+_, _ = barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+    MinRevision: newRev,
+    Timeout:     durationpb.New(5 * time.Second),
+})
+
+for _, dp := range pendingPoints {
+    status := writeOne(ctx, dp, newRev)
+    switch status {
+    case modelv1.Status_STATUS_SUCCEED:
+        // ok
+    case modelv1.Status_STATUS_SCHEMA_NOT_APPLIED:
+        // Cache caught up while the call was held but did not converge in budget.
+        // Sleep briefly or call AwaitRevisionApplied again, then retry.
+        retry(dp, newRev)
+    case modelv1.Status_STATUS_EXPIRED_SCHEMA:
+        // The schema has already advanced past newRev. Re-read the spec.
+        return refreshAndRetry()
+    }
+}
+```
+
+**Pitfall:** A retry that reuses `newRev` after another concurrent update returns `STATUS_EXPIRED_SCHEMA`. Always reload the spec (or the latest `mod_revision`) before retrying past one cycle.
+
+---
+
+## 3. Delete-then-recreate (idempotent shape)
+
+**Problem:** A reconciliation loop deletes a measure and recreates it with the same shape. The recreate must happen after the tombstone has propagated, and must satisfy the tombstone invariant.
+
+**Tools:** Delete response → `delete_time` → `AwaitSchemaDeleted` → Create with `updated_at > delete_time`.
+
+```go
+delResp, err := measureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+    Metadata: meta,
+})
+if err != nil { return err }
+delTime := delResp.GetDeleteTime()  // Unix nanoseconds
+
+if _, err := barrier.AwaitSchemaDeleted(ctx, &schemav1.AwaitSchemaDeletedRequest{
+    Keys:    []*schemav1.SchemaKey{{Kind: "measure", Group: meta.Group, Name: meta.Name}},
+    Timeout: durationpb.New(10 * time.Second),
+}); err != nil { return err }
+
+// Re-stamp UpdatedAt strictly after the tombstone's delete_time.
+freshSpec := proto.Clone(originalSpec).(*databasev1.Measure)
+freshSpec.UpdatedAt = timestamppb.Now()  // wall clock is past delTime by construction
+
+if _, err := measureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+    Measure: freshSpec,
+}); err != nil { return err }
+```
+
+**Pitfall:** Reusing `originalSpec` without re-stamping `UpdatedAt` can fail with `InvalidArgument: updated_at_before_tombstone` if the captured timestamp predates the delete.
+
+---
+
+## 4. Shape-break (incompatible recreate)
+
+**Problem:** A measure's shape needs to change in a way that is not a backward-compatible update (e.g., dropping an entity tag). The standard pattern is delete + recreate with the new shape, then resume writes against the new shape.
+
+**Tools:** Same as scenario 3, plus the time-range clamp and the write gate to harden the cutover.
+
+```go
+// 1. Tear down the old shape.
+delResp, _ := measureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{Metadata: meta})
+_, _ = barrier.AwaitSchemaDeleted(ctx, &schemav1.AwaitSchemaDeletedRequest{
+    Keys: []*schemav1.SchemaKey{{Kind: "measure", Group: meta.Group, Name: meta.Name}},
+    Timeout: durationpb.New(10 * time.Second),
+})
+
+// 2. Stand up the new shape.
+freshSpec.UpdatedAt = timestamppb.Now()
+createResp, _ := measureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{Measure: freshSpec})
+newRev := createResp.GetModRevision()
+_, _ = barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+    MinRevision: newRev,
+    Timeout:     durationpb.New(10 * time.Second),
+})
+
+// 3. Resume writes — the gate will reject any in-flight write that still carries the old revision.
+writeNewShape(ctx, newRev)
+
+// 4. Queries before the recreate's created_at return empty automatically (clamp);
+//    queries after see only new-shape data.
+queryResp, _ := measureClient.Query(ctx, &measurev1.QueryRequest{
+    Groups: []string{meta.Group},
+    Name:   meta.Name,
+    GroupModRevisions: map[string]int64{meta.Group: newRev},
+    TimeRange: trange,  // start may predate the recreate; the server clamps to created_at
+    ...
+})
+```
+
+The integration suite §6.8 (`shape-break: delete+apply new shape creates the new measure`) is the canonical regression for this pattern.
+
+**Pitfall:** Issuing queries without `group_mod_revisions` opts out of the clamp. After a shape-break, ungated queries can return mixed-shape elements until TTL eventually expires the old data. Either gate the query or wait for TTL.
+
+---
+
+## 5. Multi-group cross-cutting query
+
+**Problem:** A dashboard or batch pipeline reads from multiple groups in one `QueryRequest`. Each group has its own independent `mod_revision`, and a stale revision on one group should not block or corrupt the rest.
+
+**Tools:** Per-group `group_mod_revisions` map + per-group `group_statuses` inspection.
+
+```go
+revs := map[string]int64{
+    "sw_metric":        latestRev("sw_metric"),
+    "sw_metric_minute": latestRev("sw_metric_minute"),
+}
+
+resp, err := measureClient.Query(ctx, &measurev1.QueryRequest{
+    Groups:            []string{"sw_metric", "sw_metric_minute"},
+    Name:              "service_resp_time",
+    GroupModRevisions: revs,
+    TimeRange:         trange,
+    ...
+})
+if err != nil { return err }
+
+for group, status := range resp.GetGroupStatuses() {
+    switch status {
+    case modelv1.Status_STATUS_SUCCEED:
+        // group contributed normally
+    case modelv1.Status_STATUS_EXPIRED_SCHEMA:
+        log.Printf("group %s schema advanced; refresh and retry", group)
+    case modelv1.Status_STATUS_SCHEMA_NOT_APPLIED:
+        log.Printf("group %s lagging propagation", group)
+    }
+}
+```
+
+### Partial coverage
+
+Omit a group from the map to leave it ungated. Useful when one group is known stable but another is being actively rolled out:
+
+```go
+GroupModRevisions: map[string]int64{
+    "sw_metric": rev1,
+    // "sw_metric_minute" intentionally absent — caller does not gate it
+},
+```
+
+The §4.5.4 and §4.5.5 integration tests in `test/cases/schema/query_gate.go` exercise the mixed-status and partial-coverage variants.
+
+**Pitfall:** Building `group_mod_revisions` from a single global watermark forces every group to wait for the slowest. Either populate per-group revisions or omit the slow group from the map entirely.
+
+---
+
+## 6. Bulk schema bootstrap
+
+**Problem:** A migration creates many schemas at startup (dozens to thousands of measures across multiple groups). The caller wants to know when everything is visible before opening data ingest.
+
+**Tools:** Many Creates → collect max revision → one `AwaitRevisionApplied(maxRev)` → optional `AwaitSchemaApplied` for per-key confirmation.
+
+```go
+var maxRev int64
+keys := make([]*schemav1.SchemaKey, 0, len(specs))
+revs := make([]int64, 0, len(specs))
+
+for _, m := range specs {
+    resp, err := measureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{Measure: m})
+    if err != nil { return err }
+    r := resp.GetModRevision()
+    if r > maxRev { maxRev = r }
+    keys = append(keys, &schemav1.SchemaKey{Kind: "measure", Group: m.Metadata.Group, Name: m.Metadata.Name})
+    revs = append(revs, r)
+}
+
+// Coarse check: all schemas visible at any revision >= maxRev.
+if _, err := barrier.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+    MinRevision: maxRev,
+    Timeout:     durationpb.New(30 * time.Second),
+}); err != nil { return err }
+
+// Optional fine check: each key at its own target revision (chunk if > 10000).
+for _, batch := range chunk(keys, revs, 10000) {
+    resp, err := barrier.AwaitSchemaApplied(ctx, &schemav1.AwaitSchemaAppliedRequest{
+        Keys:         batch.keys,
+        MinRevisions: batch.revs,
+        Timeout:      durationpb.New(30 * time.Second),
+    })
+    if err != nil { return err }
+    if !resp.GetApplied() {
+        return fmt.Errorf("bulk bootstrap failed; %d laggards", len(resp.GetLaggards()))
+    }
+}
+
+// Open ingest.
+```
+
+**Pitfall:** Calling `AwaitSchemaApplied` with more than 10 000 keys returns `InvalidArgument`. Chunk the keys; the server is intentionally bounded.
+
+---
+
+## Migration appendix
+
+### Existing clients on `mod_revision = 0`
+
+Clients that do not set `Metadata.mod_revision` (because they predate v0.11.0 or because their workload does not need the gate) keep working unchanged. Every gate is a no-op when the client value is zero.
+
+### `STATUS_SCHEMA_NOT_APPLIED` enum value
+
+This is a new enum value (code 10). Strict-unmarshal clients — those that throw on unknown enum values rather than treating them as the default — must be upgraded before deploying against a v0.11.0 server.
+
+For the **Java OAP client** specifically: during the migration window, treat `STATUS_SCHEMA_NOT_APPLIED` the same as `STATUS_EXPIRED_SCHEMA` (refresh schema and retry). Once the client-side enum is updated, the two cases can be split and handled distinctly.
+
+### Tombstone retention
+
+The default tombstone retention is **7 days**, configurable via `--schema-server-tombstone-retention`. Existing tombstones become eligible for GC after `tombstone_retention` elapses from their `delete_time`. Operators on resource-constrained nodes can tune the value; the floor is 1 hour.
+
+A re-create attempted after the tombstone is GC'd is no longer subject to the `updated_at_before_tombstone` invariant — there is no tombstone to compare against. This is the expected behavior; the invariant exists to prevent replays during the retention window, not as a permanent ordering check.
+
+### Time-range clamp on pre-v0.11.0 schemas
+
+Schemas created before v0.11.0 have `created_at == nil`. The clamp is a no-op for those schemas — queries return their full configured time range as before. Once such a schema is updated, `created_at` is **not** retroactively populated; only fresh Creates stamp it.
diff --git a/docs/menu.yml b/docs/menu.yml
index caaf53e16..837de71f7 100644
--- a/docs/menu.yml
+++ b/docs/menu.yml
@@ -107,6 +107,16 @@ catalog:
         path: "/interacting/client"
       - name: "Data Lifecycle"
         path: "/interacting/data-lifecycle"
+      - name: "Schema Consistency"
+        catalog:
+          - name: "Overview"
+            path: "/interacting/schema-consistency/overview"
+          - name: "Barrier RPCs"
+            path: "/interacting/schema-consistency/barriers"
+          - name: "Revision Gates"
+            path: "/interacting/schema-consistency/revision-gates"
+          - name: "Scenarios"
+            path: "/interacting/schema-consistency/scenarios"
       - name: "BydbQL"
         path: "/interacting/bydbql"
   - name: "Operation and Maintenance"
diff --git a/pkg/meter/native/provider.go b/pkg/meter/native/provider.go
index 236ba2d03..9ac3267ca 100644
--- a/pkg/meter/native/provider.go
+++ b/pkg/meter/native/provider.go
@@ -162,7 +162,8 @@ func (p *provider) createNativeObservabilityGroup(ctx context.Context) error {
 			},
 		},
 	}
-	return p.metadata.GroupRegistry().CreateGroup(ctx, g)
+	_, createErr := p.metadata.GroupRegistry().CreateGroup(ctx, g)
+	return createErr
 }
 
 //nolint:unparam
diff --git a/pkg/schema/cache.go b/pkg/schema/cache.go
index 5d2c28400..ec45cda19 100644
--- a/pkg/schema/cache.go
+++ b/pkg/schema/cache.go
@@ -39,7 +39,10 @@ import (
 	"github.com/apache/skywalking-banyandb/pkg/run"
 )
 
-var _ Resource = (*resourceSpec)(nil)
+var (
+	_ Resource           = (*resourceSpec)(nil)
+	_ RevisionRepository = (*schemaRepo)(nil)
+)
 
 type resourceSpec struct {
 	schema    ResourceSchema
@@ -87,22 +90,101 @@ type schemaRepo struct {
 	indexRuleMap           sync.Map
 	bindingForwardMap      sync.Map
 	bindingBackwardMap     sync.Map
+	latestModRevision      atomic.Int64
 	workerNum              int
 	resourceMutex          sync.Mutex
 	groupMux               sync.Mutex
 }
 
+// SendMetadataEvent applies the given event synchronously. When the downstream
+// caches (e.g. pkg/schema.schemaRepo.groupMap and resourceMap) must be coherent
+// before the caller returns — for example, when SchemaBarrierService delegates
+// through notifyHandlers → this handler → SendMetadataEvent — the caller relies
+// on the event being fully applied here. Only transient errors queue the event
+// for retry via the background worker; the success path does not touch the
+// channel.
 func (sr *schemaRepo) SendMetadataEvent(event MetadataEvent) {
 	if !sr.closer.AddSender() {
 		return
 	}
 	defer sr.closer.SenderDone()
-	select {
-	case sr.eventCh <- event:
-	case <-sr.closer.CloseNotify():
+	if err := sr.processEvent(event); err != nil && !errors.Is(err, schema.ErrClosed) {
+		select {
+		case <-sr.closer.CloseNotify():
+			return
+		default:
+		}
+		sr.l.Err(err).Interface("event", event).Msg("fail to handle the metadata event. retry...")
+		sr.metrics.totalErrs.Inc(1)
+		select {
+		case sr.eventCh <- event:
+		case <-sr.closer.CloseNotify():
+		}
 	}
 }
 
+// processEvent applies the event in-place, bumping latestModRevision on success
+// so the barrier's downstream-handler watermark catches up before notifyHandlers
+// returns to its caller.
+func (sr *schemaRepo) processEvent(evt MetadataEvent) error {
+	if e := sr.l.Debug(); e.Enabled() {
+		e.Interface("event", evt).Msg("received an event")
+	}
+	var err error
+	switch evt.Typ {
+	case EventAddOrUpdate:
+		switch evt.Kind {
+		case EventKindGroup:
+			_, err = sr.storeGroup(evt.Metadata.GetMetadata())
+			if errors.Is(err, schema.ErrGRPCResourceNotFound) {
+				err = nil
+			}
+		case EventKindResource:
+			err = sr.storeResource(evt.Metadata)
+		case EventKindIndexRule:
+			indexRule := evt.Metadata.(*databasev1.IndexRule)
+			sr.storeIndexRule(indexRule)
+		case EventKindIndexRuleBinding:
+			indexRuleBinding := evt.Metadata.(*databasev1.IndexRuleBinding)
+			sr.storeIndexRuleBinding(indexRuleBinding)
+		}
+	case EventDelete:
+		switch evt.Kind {
+		case EventKindGroup:
+			err = sr.deleteGroup(evt.Metadata.GetMetadata())
+		case EventKindResource:
+			sr.deleteResource(evt)
+		case EventKindIndexRule:
+			key := getKey(evt.Metadata.GetMetadata())
+			sr.indexRuleMap.Delete(key)
+		case EventKindIndexRuleBinding:
+			indexRuleBinding := evt.Metadata.(*databasev1.IndexRuleBinding)
+			col, _ := sr.bindingForwardMap.Load(getKey(&commonv1.Metadata{
+				Name:  indexRuleBinding.Subject.GetName(),
+				Group: indexRuleBinding.GetMetadata().GetGroup(),
+			}))
+			if col == nil {
+				break
+			}
+			tMap := col.(*sync.Map)
+			key := getKey(indexRuleBinding.GetMetadata())
+			tMap.Delete(key)
+			for i := range indexRuleBinding.Rules {
+				col, _ := sr.bindingBackwardMap.Load(getKey(&commonv1.Metadata{
+					Name:  indexRuleBinding.Rules[i],
+					Group: indexRuleBinding.GetMetadata().GetGroup(),
+				}))
+				if col == nil {
+					continue
+				}
+				tMap := col.(*sync.Map)
+				tMap.Delete(key)
+			}
+		}
+	}
+	return err
+}
+
 // StopCh implements Repository.
 func (sr *schemaRepo) StopCh() <-chan struct{} {
 	return sr.closer.CloseNotify()
@@ -160,84 +242,25 @@ func (sr *schemaRepo) Watcher() {
 				}
 				sr.metrics.totalPanics.Inc(1)
 			}()
+			// The Watcher drains events retried via the channel after transient
+			// processing errors. The fast path runs synchronously in SendMetadataEvent
+			// so the barrier's downstream-handler watermark advances correctly.
 			for {
 				select {
 				case evt, more := <-sr.eventCh:
 					if !more {
 						return
 					}
-					if e := sr.l.Debug(); e.Enabled() {
-						e.Interface("event", evt).Msg("received an event")
-					}
-					var err error
-					switch evt.Typ {
-					case EventAddOrUpdate:
-						switch evt.Kind {
-						case EventKindGroup:
-							_, err = sr.storeGroup(evt.Metadata.GetMetadata())
-							if errors.As(err, schema.ErrGRPCResourceNotFound) {
-								err = nil
-							}
-						case EventKindResource:
-							err = sr.storeResource(evt.Metadata)
-						case EventKindIndexRule:
-							indexRule := evt.Metadata.(*databasev1.IndexRule)
-							if indexRule.GetMetadata().GetGroup() == "test-trace-group" {
-								sr.l.Info().Str("group", indexRule.GetMetadata().GetGroup()).Msg("index rule")
-							}
-							sr.storeIndexRule(indexRule)
-						case EventKindIndexRuleBinding:
-							indexRuleBinding := evt.Metadata.(*databasev1.IndexRuleBinding)
-							if indexRuleBinding.GetMetadata().GetGroup() == "test-trace-group" {
-								sr.l.Info().Str("group", indexRuleBinding.GetMetadata().GetGroup()).Msg("index rule binding")
-							}
-							sr.storeIndexRuleBinding(indexRuleBinding)
-						}
-					case EventDelete:
-						switch evt.Kind {
-						case EventKindGroup:
-							err = sr.deleteGroup(evt.Metadata.GetMetadata())
-						case EventKindResource:
-							sr.deleteResource(evt.Metadata.GetMetadata())
-						case EventKindIndexRule:
-							key := getKey(evt.Metadata.GetMetadata())
-							sr.indexRuleMap.Delete(key)
-						case EventKindIndexRuleBinding:
-							indexRuleBinding := evt.Metadata.(*databasev1.IndexRuleBinding)
-							col, _ := sr.bindingForwardMap.Load(getKey(&commonv1.Metadata{
-								Name:  indexRuleBinding.Subject.GetName(),
-								Group: indexRuleBinding.GetMetadata().GetGroup(),
-							}))
-							if col == nil {
-								break
-							}
-							tMap := col.(*sync.Map)
-							key := getKey(indexRuleBinding.GetMetadata())
-							tMap.Delete(key)
-							for i := range indexRuleBinding.Rules {
-								col, _ := sr.bindingBackwardMap.Load(getKey(&commonv1.Metadata{
-									Name:  indexRuleBinding.Rules[i],
-									Group: indexRuleBinding.GetMetadata().GetGroup(),
-								}))
-								if col == nil {
-									continue
-								}
-								tMap := col.(*sync.Map)
-								tMap.Delete(key)
-							}
-						}
-					}
-					if err != nil && !errors.Is(err, schema.ErrClosed) {
+					if retryErr := sr.processEvent(evt); retryErr != nil && !errors.Is(retryErr, schema.ErrClosed) {
 						select {
 						case <-sr.closer.CloseNotify():
 							return
 						default:
 						}
-						sr.l.Err(err).Interface("event", evt).Msg("fail to handle the metadata event. retry...")
-						sr.metrics.totalErrs.Inc(1)
+						sr.l.Err(retryErr).Interface("event", evt).Msg("retry processing failed, requeueing")
+						sr.metrics.totalRetries.Inc(1)
 						go func() {
 							sr.SendMetadataEvent(evt)
-							sr.metrics.totalRetries.Inc(1)
 						}()
 					}
 				case <-sr.closer.CloseNotify():
@@ -259,12 +282,18 @@ func (sr *schemaRepo) storeGroup(groupMeta *commonv1.Metadata) (*group, error) {
 		if err := g.init(name); err != nil {
 			return nil, err
 		}
+		if gs := g.GetSchema(); gs != nil {
+			sr.updateLatestModRevision(gs.GetMetadata().GetModRevision())
+		}
 		return g, nil
 	}
 	if !g.isInit() {
 		if err := g.init(name); err != nil {
 			return nil, err
 		}
+		if gs := g.GetSchema(); gs != nil {
+			sr.updateLatestModRevision(gs.GetMetadata().GetModRevision())
+		}
 		return g, nil
 	}
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -278,6 +307,7 @@ func (sr *schemaRepo) storeGroup(groupMeta *commonv1.Metadata) (*group, error) {
 		return g, nil
 	}
 	g.groupSchema.Store(groupSchema)
+	sr.updateLatestModRevision(groupSchema.GetMetadata().GetModRevision())
 	if proto.Equal(groupSchema, prevGroupSchema) {
 		return g, nil
 	}
@@ -365,6 +395,7 @@ func (sr *schemaRepo) storeResource(resourceSchema ResourceSchema) error {
 	sm.OnIndexUpdate(sr.indexRules(resourceSchema))
 	resource.delegated = sm
 	sr.resourceMap.Store(key, resource)
+	sr.updateLatestModRevision(resourceSchema.GetMetadata().GetModRevision())
 	return nil
 }
 
@@ -373,6 +404,7 @@ func (sr *schemaRepo) storeIndexRule(indexRule *databasev1.IndexRule) {
 	if prev, loaded := sr.indexRuleMap.LoadOrStore(key, indexRule); loaded {
 		if prev.(*databasev1.IndexRule).GetMetadata().ModRevision <= indexRule.GetMetadata().ModRevision {
 			sr.indexRuleMap.Store(key, indexRule)
+			sr.updateLatestModRevision(indexRule.GetMetadata().GetModRevision())
 			if col, _ := sr.bindingBackwardMap.Load(key); col != nil {
 				col.(*sync.Map).Range(func(_, value any) bool {
 					sr.updateIndex(value.(*databasev1.IndexRuleBinding))
@@ -381,6 +413,7 @@ func (sr *schemaRepo) storeIndexRule(indexRule *databasev1.IndexRule) {
 			}
 		}
 	} else {
+		sr.updateLatestModRevision(indexRule.GetMetadata().GetModRevision())
 		if col, _ := sr.bindingBackwardMap.Load(key); col != nil {
 			col.(*sync.Map).Range(func(_, value any) bool {
 				sr.updateIndex(value.(*databasev1.IndexRuleBinding))
@@ -425,6 +458,7 @@ func (sr *schemaRepo) storeIndexRuleBinding(indexRuleBinding *databasev1.IndexRu
 	if !changed {
 		return
 	}
+	sr.updateLatestModRevision(indexRuleBinding.GetMetadata().GetModRevision())
 	sr.updateIndex(indexRuleBinding)
 }
 
@@ -468,11 +502,74 @@ func getKey(metadata *commonv1.Metadata) string {
 	return path.Join(metadata.GetGroup(), metadata.GetName())
 }
 
-func (sr *schemaRepo) deleteResource(metadata *commonv1.Metadata) {
-	key := getKey(metadata)
+func (sr *schemaRepo) deleteResource(evt MetadataEvent) {
+	key := getKey(evt.Metadata.GetMetadata())
+	// Hold resourceMutex across the load/check/delete to keep the revision-guard atomic.
+	// storeResource takes the same lock, so a concurrent newer store cannot interleave
+	// between the staleness check and LoadAndDelete and have its entry wiped.
+	sr.resourceMutex.Lock()
+	defer sr.resourceMutex.Unlock()
+	if evt.DeleteRevision != 0 {
+		if v, ok := sr.resourceMap.Load(key); ok {
+			stored := v.(*resourceSpec)
+			if evt.DeleteRevision < stored.maxRevision() {
+				return
+			}
+		}
+	}
 	_, _ = sr.resourceMap.LoadAndDelete(key)
 }
 
+func (sr *schemaRepo) updateLatestModRevision(incoming int64) {
+	for {
+		cur := sr.latestModRevision.Load()
+		if incoming <= cur {
+			return
+		}
+		if sr.latestModRevision.CompareAndSwap(cur, incoming) {
+			return
+		}
+	}
+}
+
+// LatestModRevision returns the highest mod_revision seen across all stored kinds.
+func (sr *schemaRepo) LatestModRevision() int64 {
+	return sr.latestModRevision.Load()
+}
+
+// ResourceRevision returns the mod_revision for a stored resource and whether it was found.
+func (sr *schemaRepo) ResourceRevision(kind schema.Kind, groupName, name string) (int64, bool) {
+	key := path.Join(groupName, name)
+	switch kind {
+	case schema.KindStream, schema.KindMeasure, schema.KindTrace:
+		if v, ok := sr.resourceMap.Load(key); ok {
+			return v.(*resourceSpec).maxRevision(), true
+		}
+	case schema.KindIndexRule:
+		if v, ok := sr.indexRuleMap.Load(key); ok {
+			return v.(*databasev1.IndexRule).GetMetadata().GetModRevision(), true
+		}
+	case schema.KindGroup:
+		if v, ok := sr.groupMap.Load(name); ok {
+			grp := v.(*group)
+			if gs := grp.GetSchema(); gs != nil {
+				return gs.GetMetadata().GetModRevision(), true
+			}
+		}
+	case schema.KindIndexRuleBinding, schema.KindTopNAggregation,
+		schema.KindNode, schema.KindProperty, schema.KindMask:
+		// schemaRepo only caches resources, index rules, and groups; other kinds
+		// (bindings, top-n aggregations, nodes, properties, masks) have no entry here.
+	}
+	return 0, false
+}
+
+// IsAbsent returns true when the given resource is not present in the local cache.
+func (sr *schemaRepo) IsAbsent(kind schema.Kind, groupName, name string) bool {
+	_, ok := sr.ResourceRevision(kind, groupName, name)
+	return !ok
+}
+
 func (sr *schemaRepo) Close() {
 	defer func() {
 		if err := recover(); err != nil {
diff --git a/pkg/schema/cache_test.go b/pkg/schema/cache_test.go
new file mode 100644
index 000000000..d524e238b
--- /dev/null
+++ b/pkg/schema/cache_test.go
@@ -0,0 +1,170 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	"github.com/apache/skywalking-banyandb/banyand/metadata/schema"
+)
+
+// noopIndexListener satisfies IndexListener and discards all index updates.
+type noopIndexListener struct{}
+
+func (noopIndexListener) OnIndexUpdate(_ []*databasev1.IndexRule) {}
+
+// stubSupplier satisfies ResourceSchemaSupplier for tests that only need
+// storeResource to succeed without real DB or metadata interactions.
+type stubSupplier struct{}
+
+func (stubSupplier) ResourceSchema(_ *commonv1.Metadata) (ResourceSchema, error) {
+	return nil, nil
+}
+
+func (stubSupplier) OpenResource(_ Resource) (IndexListener, error) {
+	return noopIndexListener{}, nil
+}
+
+// newTestSchemaRepo creates a minimal schemaRepo suitable for unit tests.
+// The zero values of all sync.Map and atomic fields are ready to use.
+func newTestSchemaRepo() *schemaRepo {
+	return &schemaRepo{resourceSchemaSupplier: stubSupplier{}}
+}
+
+// buildMeasure returns a *databasev1.Measure with the specified name and mod_revision.
+// Group is fixed to "g" because every test in this file uses the same group; varying
+// it would only add noise without changing what's being asserted.
+func buildMeasure(name string, modRevision int64) *databasev1.Measure {
+	return &databasev1.Measure{
+		Metadata: &commonv1.Metadata{Group: "g", Name: name, ModRevision: modRevision},
+	}
+}
+
+// TestDeleteResource_RevisionGuard_RejectsStaleDelete verifies that an EventDelete
+// carrying a DeleteRevision lower than the stored resource's mod_revision is ignored,
+// leaving the cache entry intact (A7).
+func TestDeleteResource_RevisionGuard_RejectsStaleDelete(t *testing.T) {
+	sr := newTestSchemaRepo()
+	require.NoError(t, sr.storeResource(buildMeasure("m", 100)))
+
+	sr.deleteResource(MetadataEvent{
+		Metadata:       buildMeasure("m", 100),
+		DeleteRevision: 50,
+		Typ:            EventDelete,
+		Kind:           EventKindResource,
+	})
+
+	_, present := sr.resourceMap.Load("g/m")
+	assert.True(t, present, "stale delete (DeleteRevision=50 < stored rev=100) must not remove the entry")
+}
+
+// TestDeleteResource_RevisionGuard_AcceptsFreshDelete verifies that an EventDelete
+// carrying a DeleteRevision greater than the stored resource's mod_revision removes
+// the cache entry (A7).
+func TestDeleteResource_RevisionGuard_AcceptsFreshDelete(t *testing.T) {
+	sr := newTestSchemaRepo()
+	require.NoError(t, sr.storeResource(buildMeasure("m", 100)))
+
+	sr.deleteResource(MetadataEvent{
+		Metadata:       buildMeasure("m", 100),
+		DeleteRevision: 150,
+		Typ:            EventDelete,
+		Kind:           EventKindResource,
+	})
+
+	_, present := sr.resourceMap.Load("g/m")
+	assert.False(t, present, "fresh delete (DeleteRevision=150 >= stored rev=100) must remove the entry")
+}
+
+// TestLatestModRevision_Monotonic verifies that LatestModRevision advances monotonically
+// with ascending stores and does not regress when a lower revision is applied (A8).
+func TestLatestModRevision_Monotonic(t *testing.T) {
+	sr := newTestSchemaRepo()
+	assert.Equal(t, int64(0), sr.LatestModRevision(), "watermark must start at zero")
+
+	require.NoError(t, sr.storeResource(buildMeasure("m1", 10)))
+	assert.Equal(t, int64(10), sr.LatestModRevision())
+
+	require.NoError(t, sr.storeResource(buildMeasure("m2", 30)))
+	assert.Equal(t, int64(30), sr.LatestModRevision())
+
+	// A new key at a lower revision must not regress the watermark.
+	require.NoError(t, sr.storeResource(buildMeasure("m3", 20)))
+	assert.Equal(t, int64(30), sr.LatestModRevision(), "stale store (rev=20) must not lower the watermark from 30")
+}
+
+// TestLatestModRevision_AcrossKinds verifies that LatestModRevision tracks the global
+// maximum across Resource, IndexRule, and IndexRuleBinding stores (A8).
+func TestLatestModRevision_AcrossKinds(t *testing.T) {
+	sr := newTestSchemaRepo()
+
+	require.NoError(t, sr.storeResource(buildMeasure("m1", 10)))
+	assert.Equal(t, int64(10), sr.LatestModRevision())
+
+	sr.storeIndexRule(&databasev1.IndexRule{
+		Metadata: &commonv1.Metadata{Group: "g", Name: "idx1", ModRevision: 50},
+		Tags:     []string{"tag1"},
+		Type:     databasev1.IndexRule_TYPE_INVERTED,
+	})
+	assert.Equal(t, int64(50), sr.LatestModRevision(), "IndexRule store (rev=50) must advance the watermark")
+
+	sr.storeIndexRuleBinding(&databasev1.IndexRuleBinding{
+		Metadata: &commonv1.Metadata{Group: "g", Name: "irb1", ModRevision: 40},
+		Rules:    []string{"idx1"},
+		Subject:  &databasev1.Subject{Catalog: commonv1.Catalog_CATALOG_MEASURE, Name: "m1"},
+	})
+	assert.Equal(t, int64(50), sr.LatestModRevision(), "IndexRuleBinding at rev=40 must not lower the watermark from 50")
+}
+
+// TestResourceRevision_PresentAndAbsent verifies that ResourceRevision returns the
+// stored mod_revision and ok=true for a present resource, and (0, false) for an absent one (A8).
+func TestResourceRevision_PresentAndAbsent(t *testing.T) {
+	sr := newTestSchemaRepo()
+	require.NoError(t, sr.storeResource(buildMeasure("m", 77)))
+
+	rev, ok := sr.ResourceRevision(schema.KindMeasure, "g", "m")
+	assert.True(t, ok)
+	assert.Equal(t, int64(77), rev)
+
+	rev, ok = sr.ResourceRevision(schema.KindMeasure, "g", "missing")
+	assert.False(t, ok)
+	assert.Equal(t, int64(0), rev)
+}
+
+// TestIsAbsent_AfterDelete verifies that IsAbsent returns false while the resource
+// is cached and true after a valid delete removes it from the cache (A7, A8).
+func TestIsAbsent_AfterDelete(t *testing.T) {
+	sr := newTestSchemaRepo()
+	require.NoError(t, sr.storeResource(buildMeasure("m", 100)))
+
+	assert.False(t, sr.IsAbsent(schema.KindMeasure, "g", "m"), "resource must not be absent immediately after store")
+
+	sr.deleteResource(MetadataEvent{
+		Metadata:       buildMeasure("m", 100),
+		DeleteRevision: 100,
+		Typ:            EventDelete,
+		Kind:           EventKindResource,
+	})
+
+	assert.True(t, sr.IsAbsent(schema.KindMeasure, "g", "m"), "resource must be absent after a valid delete")
+}
diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go
index e99bcce9c..13625a7c0 100644
--- a/pkg/schema/schema.go
+++ b/pkg/schema/schema.go
@@ -55,9 +55,10 @@ type Group interface {
 
 // MetadataEvent is the syncing message between metadata repo and this framework.
 type MetadataEvent struct {
-	Metadata ResourceSchema
-	Typ      EventType
-	Kind     EventKind
+	Metadata       ResourceSchema
+	DeleteRevision int64 // non-zero only for EventDelete; carries the tombstone mod_revision
+	Typ            EventType
+	Kind           EventKind
 }
 
 // ResourceSchema allows get the metadata.
@@ -112,3 +113,12 @@ type Repository interface {
 	StopCh() <-chan struct{}
 	DropGroup(groupName string) error
 }
+
+// RevisionRepository extends Repository with revision-query methods used by
+// the write/query gates and the barrier implementation.
+type RevisionRepository interface {
+	Repository
+	LatestModRevision() int64
+	ResourceRevision(kind schema.Kind, group, name string) (int64, bool)
+	IsAbsent(kind schema.Kind, group, name string) bool
+}
diff --git a/pkg/test/helpers/constant.go b/pkg/test/helpers/constant.go
index cd4aea32f..14d1b60c0 100644
--- a/pkg/test/helpers/constant.go
+++ b/pkg/test/helpers/constant.go
@@ -33,8 +33,9 @@ import (
 
 // SharedContext is the context shared between test cases in the integration testing.
 type SharedContext struct {
-	Connection *grpclib.ClientConn
 	BaseTime   time.Time
+	Connection *grpclib.ClientConn
+	Mode       string
 }
 
 // Args is a wrapper seals all necessary info for table specs.
diff --git a/pkg/test/measure/schema.go b/pkg/test/measure/schema.go
index cedd2e458..573e15ea7 100644
--- a/pkg/test/measure/schema.go
+++ b/pkg/test/measure/schema.go
@@ -66,17 +66,20 @@ func PreloadResourcesOnly(ctx context.Context, e schema.Registry) error {
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(indexRuleDir, &databasev1.IndexRule{}, func(indexRule *databasev1.IndexRule) error {
-				return e.CreateIndexRule(ctx, indexRule)
+				_, innerErr := e.CreateIndexRule(ctx, indexRule)
+				return innerErr
 			})
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(indexRuleBinding *databasev1.IndexRuleBinding) error {
-				return e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+				_, innerErr := e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+				return innerErr
 			})
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(topNAggregationDir, &databasev1.TopNAggregation{}, func(topN *databasev1.TopNAggregation) error {
-				return e.CreateTopNAggregation(ctx, topN)
+				_, innerErr := e.CreateTopNAggregation(ctx, topN)
+				return innerErr
 			})
 		},
 	)
@@ -87,7 +90,8 @@ func loadAllSchemas(ctx context.Context, e schema.Registry, groupDirectory strin
 	return preloadSchemaWithFuncs(ctx, e,
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(groupDirectory, &commonv1.Group{}, func(group *commonv1.Group) error {
-				return e.CreateGroup(ctx, group)
+				_, innerErr := e.CreateGroup(ctx, group)
+				return innerErr
 			})
 		},
 		func(ctx context.Context, e schema.Registry) error {
@@ -98,17 +102,20 @@ func loadAllSchemas(ctx context.Context, e schema.Registry, groupDirectory strin
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(indexRuleDir, &databasev1.IndexRule{}, func(indexRule *databasev1.IndexRule) error {
-				return e.CreateIndexRule(ctx, indexRule)
+				_, innerErr := e.CreateIndexRule(ctx, indexRule)
+				return innerErr
 			})
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(indexRuleBinding *databasev1.IndexRuleBinding) error {
-				return e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+				_, innerErr := e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+				return innerErr
 			})
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(topNAggregationDir, &databasev1.TopNAggregation{}, func(topN *databasev1.TopNAggregation) error {
-				return e.CreateTopNAggregation(ctx, topN)
+				_, innerErr := e.CreateTopNAggregation(ctx, topN)
+				return innerErr
 			})
 		},
 	)
diff --git a/pkg/test/property/schema.go b/pkg/test/property/schema.go
index c6bbfa35c..d3538ee68 100644
--- a/pkg/test/property/schema.go
+++ b/pkg/test/property/schema.go
@@ -88,7 +88,7 @@ func PreloadSchema(ctx context.Context, e schema.Registry) error {
 			logger.Infof("group %s already exists", g.Metadata.Name)
 			return nil
 		}
-		if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+		if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
 			return innerErr
 		}
 	}
diff --git a/pkg/test/replicated/measure/etcd.go b/pkg/test/replicated/measure/etcd.go
index 506ae7bee..b6331fa05 100644
--- a/pkg/test/replicated/measure/etcd.go
+++ b/pkg/test/replicated/measure/etcd.go
@@ -45,7 +45,8 @@ var store embed.FS
 // PreloadSchema loads schemas from files in the booting process.
 func PreloadSchema(ctx context.Context, e schema.Registry) error {
 	if err := loadSchema(groupDir, &commonv1.Group{}, func(group *commonv1.Group) error {
-		return e.CreateGroup(ctx, group)
+		_, innerErr := e.CreateGroup(ctx, group)
+		return innerErr
 	}); err != nil {
 		return errors.WithStack(err)
 	}
@@ -53,7 +54,8 @@ func PreloadSchema(ctx context.Context, e schema.Registry) error {
 		return errors.WithStack(loadErr)
 	}
 	return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(irb *databasev1.IndexRuleBinding) error {
-		return e.CreateIndexRuleBinding(ctx, irb)
+		_, innerErr := e.CreateIndexRuleBinding(ctx, irb)
+		return innerErr
 	})
 }
 
diff --git a/pkg/test/replicated/stream/etcd.go b/pkg/test/replicated/stream/etcd.go
index 8da2e4feb..14ff1375d 100644
--- a/pkg/test/replicated/stream/etcd.go
+++ b/pkg/test/replicated/stream/etcd.go
@@ -58,7 +58,7 @@ func LoadSchemaWithStages(ctx context.Context, e schema.Registry) error {
 			logger.Infof("group %s already exists", g.Metadata.Name)
 			return nil
 		}
-		if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+		if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
 			return innerErr
 		}
 	}
@@ -85,7 +85,7 @@ func PreloadSchema(ctx context.Context, e schema.Registry) error {
 			logger.Infof("group %s already exists", g.Metadata.Name)
 			return nil
 		}
-		if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+		if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
 			return innerErr
 		}
 	}
diff --git a/pkg/test/replicated/trace/etcd.go b/pkg/test/replicated/trace/etcd.go
index b528fe7dd..fe25149a7 100644
--- a/pkg/test/replicated/trace/etcd.go
+++ b/pkg/test/replicated/trace/etcd.go
@@ -41,7 +41,8 @@ var store embed.FS
 // PreloadSchema loads schemas from files in the booting process.
 func PreloadSchema(ctx context.Context, e schema.Registry) error {
 	if err := loadSchema(groupDir, &commonv1.Group{}, func(group *commonv1.Group) error {
-		return e.CreateGroup(ctx, group)
+		_, createErr := e.CreateGroup(ctx, group)
+		return createErr
 	}); err != nil {
 		return errors.WithStack(err)
 	}
diff --git a/pkg/test/setup/watch_control.go b/pkg/test/setup/watch_control.go
new file mode 100644
index 000000000..69e2b60f8
--- /dev/null
+++ b/pkg/test/setup/watch_control.go
@@ -0,0 +1,42 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package setup
+
+import "errors"
+
+// ErrWatchControlNotImplemented is returned by PauseDataNodeWatch and
+// ResumeDataNodeWatch until the data-node watch control hooks land in
+// Phase 2 (Step 2.8). Cluster-only specs in §6.12 of the schema-consistency
+// plan must skip themselves when this sentinel surfaces.
+var ErrWatchControlNotImplemented = errors.New("watch control not implemented: landed in Phase 2")
+
+// PauseDataNodeWatch suspends the named data node's schema watch loop so a
+// test can observe behavior while the node is missing schema events. It is a
+// distributed-only hook; the standalone harness has no separate watch path.
+// Phase 2 will provide a real implementation; until then it returns
+// ErrWatchControlNotImplemented and callers should skip.
+func PauseDataNodeWatch(_ string) error {
+	return ErrWatchControlNotImplemented
+}
+
+// ResumeDataNodeWatch resumes a previously paused data-node watch loop. Same
+// Phase 2 caveat as PauseDataNodeWatch: it currently returns
+// ErrWatchControlNotImplemented so cluster-only specs can self-skip.
+func ResumeDataNodeWatch(_ string) error {
+	return ErrWatchControlNotImplemented
+}
diff --git a/pkg/test/stream/schema.go b/pkg/test/stream/schema.go
index f70c5627a..25e8e82b9 100644
--- a/pkg/test/stream/schema.go
+++ b/pkg/test/stream/schema.go
@@ -94,7 +94,7 @@ func loadSchemas(ctx context.Context, e schema.Registry) error {
 		if err != nil {
 			return err
 		}
-		if innerErr := e.CreateIndexRule(ctx, &idxRule); innerErr != nil {
+		if _, innerErr := e.CreateIndexRule(ctx, &idxRule); innerErr != nil {
 			return innerErr
 		}
 	}
@@ -112,7 +112,7 @@ func loadSchemas(ctx context.Context, e schema.Registry) error {
 		if err != nil {
 			return err
 		}
-		if innerErr := e.CreateIndexRuleBinding(ctx, &idxRuleBinding); innerErr != nil {
+		if _, innerErr := e.CreateIndexRuleBinding(ctx, &idxRuleBinding); innerErr != nil {
 			return innerErr
 		}
 	}
@@ -139,7 +139,7 @@ func LoadSchemaWithStages(ctx context.Context, e schema.Registry) error {
 			logger.Infof("group %s already exists", g.Metadata.Name)
 			return nil
 		}
-		if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+		if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
 			return innerErr
 		}
 	}
@@ -166,7 +166,7 @@ func PreloadSchema(ctx context.Context, e schema.Registry) error {
 			logger.Infof("group %s already exists", g.Metadata.Name)
 			return nil
 		}
-		if innerErr := e.CreateGroup(ctx, g); innerErr != nil {
+		if _, innerErr := e.CreateGroup(ctx, g); innerErr != nil {
 			return innerErr
 		}
 	}
diff --git a/pkg/test/trace/schema.go b/pkg/test/trace/schema.go
index 457272923..e5bb81a3f 100644
--- a/pkg/test/trace/schema.go
+++ b/pkg/test/trace/schema.go
@@ -65,12 +65,14 @@ func PreloadResourcesOnly(ctx context.Context, e schema.Registry) error {
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(indexRuleDir, &databasev1.IndexRule{}, func(indexRule *databasev1.IndexRule) error {
-				return e.CreateIndexRule(ctx, indexRule)
+				_, innerErr := e.CreateIndexRule(ctx, indexRule)
+				return innerErr
 			})
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(indexRuleBinding *databasev1.IndexRuleBinding) error {
-				return e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+				_, innerErr := e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+				return innerErr
 			})
 		},
 	)
@@ -81,7 +83,8 @@ func loadAllSchemas(ctx context.Context, e schema.Registry, group string) error
 	return preloadSchemaWithFuncs(ctx, e,
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(group, &commonv1.Group{}, func(group *commonv1.Group) error {
-				return e.CreateGroup(ctx, group)
+				_, innerErr := e.CreateGroup(ctx, group)
+				return innerErr
 			})
 		},
 		func(ctx context.Context, e schema.Registry) error {
@@ -92,12 +95,14 @@ func loadAllSchemas(ctx context.Context, e schema.Registry, group string) error
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(indexRuleDir, &databasev1.IndexRule{}, func(indexRule *databasev1.IndexRule) error {
-				return e.CreateIndexRule(ctx, indexRule)
+				_, innerErr := e.CreateIndexRule(ctx, indexRule)
+				return innerErr
 			})
 		},
 		func(ctx context.Context, e schema.Registry) error {
 			return loadSchema(indexRuleBindingDir, &databasev1.IndexRuleBinding{}, func(indexRuleBinding *databasev1.IndexRuleBinding) error {
-				return e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+				_, innerErr := e.CreateIndexRuleBinding(ctx, indexRuleBinding)
+				return innerErr
 			})
 		},
 	)
diff --git a/test/cases/schema/apply_rollback.go b/test/cases/schema/apply_rollback.go
new file mode 100644
index 000000000..0b975af25
--- /dev/null
+++ b/test/cases/schema/apply_rollback.go
@@ -0,0 +1,525 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/proto"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+)
+
+// arMeasureGroup creates a CATALOG_MEASURE group suitable for apply-rollback tests.
+func arMeasureGroup(name string) *commonv1.Group {
+	return &commonv1.Group{
+		Metadata: &commonv1.Metadata{Name: name},
+		Catalog:  commonv1.Catalog_CATALOG_MEASURE,
+		ResourceOpts: &commonv1.ResourceOpts{
+			ShardNum:        2,
+			SegmentInterval: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 1},
+			Ttl:             &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 7},
+		},
+	}
+}
+
+// arMeasureSpec returns a minimal Measure with entity=[host] for apply-rollback tests.
+func arMeasureSpec(group, name string) *databasev1.Measure {
+	return &databasev1.Measure{
+		Metadata: &commonv1.Metadata{Name: name, Group: group},
+		Entity:   &databasev1.Entity{TagNames: []string{"host"}},
+		TagFamilies: []*databasev1.TagFamilySpec{{
+			Name: "default",
+			Tags: []*databasev1.TagSpec{
+				{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+			},
+		}},
+		Fields: []*databasev1.FieldSpec{{
+			Name:              "value",
+			FieldType:         databasev1.FieldType_FIELD_TYPE_INT,
+			EncodingMethod:    databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+			CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+		}},
+	}
+}
+
+// applyRollbackMeasureName is the fixture measure name reused across the §6.1–§6.7 specs.
+const applyRollbackMeasureName = "cpu_total"
+
+// Schema apply-rollback tests — §6.1 through §6.7.
+var _ = g.Describe("Schema apply rollback", func() {
+	var (
+		ctx     context.Context
+		clients *Clients
+	)
+
+	g.BeforeEach(func() {
+		ctx = context.Background()
+		clients = NewClients(SharedContext.Connection)
+	})
+
+	// §6.1 — creates a measure visible end-to-end.
+	g.It("creates a measure visible end-to-end (§6.1)", func() {
+		groupName := fmt.Sprintf("ar-vm-%d", time.Now().UnixNano())
+		measureName := applyRollbackMeasureName
+
+		g.By("Creating measure group and awaiting revision")
+		createGroupResp, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: arMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+		groupRev := createGroupResp.GetModRevision()
+		gm.Expect(groupRev).Should(gm.BeNumerically(">", int64(0)))
+
+		awaitGroupErr := clients.AwaitRevision(ctx, groupRev, 10*time.Second)
+		gm.Expect(awaitGroupErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating measure and awaiting revision")
+		createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+		r := createMeasureResp.GetModRevision()
+		gm.Expect(r).Should(gm.BeNumerically(">", int64(0)))
+
+		awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Verifying GetMeasure returns correct metadata")
+		getResp, getErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr).ShouldNot(gm.HaveOccurred())
+		got := getResp.GetMeasure()
+		gm.Expect(got.GetMetadata().GetModRevision()).Should(gm.Equal(r),
+			"GetMeasure.ModRevision must equal the create response ModRevision")
+		gm.Expect(got.GetCreatedAt()).ShouldNot(gm.BeNil(), "CreatedAt must be set")
+		gm.Expect(got.GetUpdatedAt()).ShouldNot(gm.BeNil(), "UpdatedAt must be set")
+		gm.Expect(got.GetCreatedAt().AsTime().Equal(got.GetUpdatedAt().AsTime())).Should(gm.BeTrue(),
+			"CreatedAt must equal UpdatedAt on first create")
+
+		g.By("Verifying ListMeasure returns exactly one user-created entry")
+		listResp, listErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listErr).ShouldNot(gm.HaveOccurred())
+		userOnly := userMeasures(listResp.GetMeasure())
+		gm.Expect(userOnly).Should(gm.HaveLen(1), "ListMeasure must return exactly one user-created entry")
+		gm.Expect(userOnly[0].GetMetadata().GetName()).Should(gm.Equal(measureName))
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.2 — Apply-rollback: a rejected schema update must leave the full schema state intact,
+	// including mod_revision, created_at, updated_at, and the entity definition.
+	g.It("preserves mod_revision, created_at, updated_at, and entity after a rejected entity-change update (§6.2)", func() {
+		groupName := fmt.Sprintf("ar-measure-%d", time.Now().UnixNano())
+		measureName := "ar_measure"
+
+		g.By("Creating measure group")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: &commonv1.Group{
+				Metadata: &commonv1.Metadata{Name: groupName},
+				Catalog:  commonv1.Catalog_CATALOG_MEASURE,
+				ResourceOpts: &commonv1.ResourceOpts{
+					ShardNum:        2,
+					SegmentInterval: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 1},
+					Ttl:             &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 7},
+				},
+			},
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating measure with entity=[svc]")
+		_, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: &databasev1.Measure{
+				Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+				Entity:   &databasev1.Entity{TagNames: []string{"svc"}},
+				TagFamilies: []*databasev1.TagFamilySpec{{
+					Name: "default",
+					Tags: []*databasev1.TagSpec{
+						{Name: "svc", Type: databasev1.TagType_TAG_TYPE_STRING},
+						{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+					},
+				}},
+				Fields: []*databasev1.FieldSpec{{
+					Name:              "value",
+					FieldType:         databasev1.FieldType_FIELD_TYPE_INT,
+					EncodingMethod:    databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+					CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+				}},
+			},
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Getting measure to capture initial state")
+		initialResp, getInitialErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getInitialErr).ShouldNot(gm.HaveOccurred())
+		initial := initialResp.GetMeasure()
+		gm.Expect(initial.GetCreatedAt()).ShouldNot(gm.BeNil())
+		gm.Expect(initial.GetUpdatedAt()).ShouldNot(gm.BeNil())
+		gm.Expect(initial.GetMetadata().GetModRevision()).Should(gm.BeNumerically(">", int64(0)))
+		initialModRev := initial.GetMetadata().GetModRevision()
+		initialCreatedAt := initial.GetCreatedAt().AsTime().UnixNano()
+		initialUpdatedAt := initial.GetUpdatedAt().AsTime().UnixNano()
+
+		g.By("Attempting UpdateMeasure with entity=[svc, host] (entity change that must be rejected)")
+		changedMeasure := proto.Clone(initial).(*databasev1.Measure)
+		changedMeasure.Entity = &databasev1.Entity{TagNames: []string{"svc", "host"}}
+		_, updateErr := clients.MeasureRegClient.Update(ctx, &databasev1.MeasureRegistryServiceUpdateRequest{
+			Measure: changedMeasure,
+		})
+		gm.Expect(updateErr).Should(gm.HaveOccurred(), "UpdateMeasure with entity change must return an error")
+
+		g.By("Verifying mod_revision, created_at, updated_at, and entity are all unchanged")
+		afterResp, getAfterErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getAfterErr).ShouldNot(gm.HaveOccurred())
+		after := afterResp.GetMeasure()
+
+		gm.Expect(after.GetMetadata().GetModRevision()).Should(
+			gm.Equal(initialModRev),
+			"mod_revision must be unchanged after a rejected update",
+		)
+		gm.Expect(after.GetCreatedAt()).ShouldNot(gm.BeNil())
+		gm.Expect(after.GetCreatedAt().AsTime().UnixNano()).Should(
+			gm.Equal(initialCreatedAt),
+			"created_at must be unchanged after a rejected update",
+		)
+		gm.Expect(after.GetUpdatedAt()).ShouldNot(gm.BeNil())
+		gm.Expect(after.GetUpdatedAt().AsTime().UnixNano()).Should(
+			gm.Equal(initialUpdatedAt),
+			"updated_at must be unchanged after a rejected update",
+		)
+		gm.Expect(after.GetEntity().GetTagNames()).Should(
+			gm.Equal([]string{"svc"}),
+			"entity must be unchanged after a rejected update",
+		)
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.3 — lists groups and measures after create.
+	g.It("lists groups and measures after create (§6.3)", func() {
+		groupName := fmt.Sprintf("ar-list-%d", time.Now().UnixNano())
+		measureName := applyRollbackMeasureName
+
+		g.By("Creating measure group and measure")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: arMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+		r := createMeasureResp.GetModRevision()
+
+		g.By("Awaiting revision")
+		awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("ListGroup contains the group")
+		listGroupResp, listGroupErr := clients.GroupClient.List(ctx, &databasev1.GroupRegistryServiceListRequest{})
+		gm.Expect(listGroupErr).ShouldNot(gm.HaveOccurred())
+		foundGroup := false
+		for _, grp := range listGroupResp.GetGroup() {
+			if grp.GetMetadata().GetName() == groupName {
+				foundGroup = true
+				break
+			}
+		}
+		gm.Expect(foundGroup).Should(gm.BeTrue(), "ListGroup must contain the created group")
+
+		g.By("ListMeasure returns exactly one user-created entry")
+		listMeasureResp, listMeasureErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listMeasureErr).ShouldNot(gm.HaveOccurred())
+		userMeasureOnly := userMeasures(listMeasureResp.GetMeasure())
+		gm.Expect(userMeasureOnly).Should(gm.HaveLen(1))
+		gm.Expect(userMeasureOnly[0].GetMetadata().GetName()).Should(gm.Equal(measureName))
+
+		g.By("ListStream, ListTrace, ListIndexRule, ListIndexRuleBinding, ListTopNAggregation all empty")
+		listStreamResp, listStreamErr := clients.StreamRegClient.List(ctx, &databasev1.StreamRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listStreamErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listStreamResp.GetStream()).Should(gm.BeEmpty(), "ListStream must be empty")
+
+		listTraceResp, listTraceErr := clients.TraceRegClient.List(ctx, &databasev1.TraceRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listTraceErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listTraceResp.GetTrace()).Should(gm.BeEmpty(), "ListTrace must be empty")
+
+		listIdxRuleResp, listIdxRuleErr := clients.IndexRuleClient.List(ctx, &databasev1.IndexRuleRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listIdxRuleErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listIdxRuleResp.GetIndexRule()).Should(gm.BeEmpty(), "ListIndexRule must be empty")
+
+		listIdxBindResp, listIdxBindErr := clients.IndexRuleBindingClient.List(ctx, &databasev1.IndexRuleBindingRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listIdxBindErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listIdxBindResp.GetIndexRuleBinding()).Should(gm.BeEmpty(), "ListIndexRuleBinding must be empty")
+
+		listTopNResp, listTopNErr := clients.TopNAggregationRegClient.List(ctx, &databasev1.TopNAggregationRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listTopNErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listTopNResp.GetTopNAggregation()).Should(gm.BeEmpty(), "ListTopNAggregation must be empty")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.4 — adds a second measure and keeps the first.
+	g.It("adds a second measure and keeps the first (§6.4)", func() {
+		groupName := fmt.Sprintf("ar-two-%d", time.Now().UnixNano())
+
+		g.By("Creating measure group")
+		createGroupResp, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: arMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+		groupRev := createGroupResp.GetModRevision()
+		awaitGroupErr := clients.AwaitRevision(ctx, groupRev, 10*time.Second)
+		gm.Expect(awaitGroupErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating first measure cpu_total → R1")
+		createResp1, createErr1 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, applyRollbackMeasureName),
+		})
+		gm.Expect(createErr1).ShouldNot(gm.HaveOccurred())
+		r1 := createResp1.GetModRevision()
+		gm.Expect(r1).Should(gm.BeNumerically(">", int64(0)))
+
+		awaitErr1 := clients.AwaitRevision(ctx, r1, 10*time.Second)
+		gm.Expect(awaitErr1).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating second measure mem_total → R2 > R1")
+		createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, "mem_total"),
+		})
+		gm.Expect(createErr2).ShouldNot(gm.HaveOccurred())
+		r2 := createResp2.GetModRevision()
+		gm.Expect(r2).Should(gm.BeNumerically(">", r1), "R2 must be > R1")
+
+		awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+		gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+		g.By("Verifying GetMeasure for cpu_total returns R1")
+		getCPUResp, getCPUErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: applyRollbackMeasureName, Group: groupName},
+		})
+		gm.Expect(getCPUErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(getCPUResp.GetMeasure().GetMetadata().GetModRevision()).Should(gm.Equal(r1),
+			"cpu_total ModRevision must equal R1")
+
+		g.By("Verifying GetMeasure for mem_total returns R2")
+		getMemResp, getMemErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: "mem_total", Group: groupName},
+		})
+		gm.Expect(getMemErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(getMemResp.GetMeasure().GetMetadata().GetModRevision()).Should(gm.Equal(r2),
+			"mem_total ModRevision must equal R2")
+
+		g.By("ListMeasure returns both user-created measures")
+		listResp, listErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listErr).ShouldNot(gm.HaveOccurred())
+		userOnly := userMeasures(listResp.GetMeasure())
+		gm.Expect(userOnly).Should(gm.HaveLen(2), "ListMeasure must return both user-created measures")
+		names := make([]string, 0, 2)
+		for _, m := range userOnly {
+			names = append(names, m.GetMetadata().GetName())
+		}
+		gm.Expect(names).Should(gm.ConsistOf(applyRollbackMeasureName, "mem_total"))
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.5 — drops measure from BanyanDB on delete.
+	g.It("drops measure from BanyanDB on delete (§6.5)", func() {
+		groupName := fmt.Sprintf("ar-del-%d", time.Now().UnixNano())
+		measureName := applyRollbackMeasureName
+
+		g.By("Creating measure group and measure")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: arMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+		r := createMeasureResp.GetModRevision()
+
+		awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Deleting measure and verifying delete response")
+		deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(deleteResp.GetDeleted()).Should(gm.BeTrue(), "DeleteMeasure must return deleted=true")
+		gm.Expect(deleteResp.GetDeleteTime()).Should(gm.BeNumerically(">", int64(0)), "delete_time must be > 0")
+
+		g.By("Awaiting deletion propagation")
+		awaitDeletedErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+		gm.Expect(awaitDeletedErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Verifying GetMeasure returns NotFound")
+		_, getErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr).Should(gm.HaveOccurred())
+		st, _ := status.FromError(getErr)
+		gm.Expect(st.Code()).Should(gm.Equal(codes.NotFound), "GetMeasure must return NotFound after deletion")
+
+		g.By("Verifying ListMeasure has no user-created entries")
+		listResp, listErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(userMeasures(listResp.GetMeasure())).Should(gm.BeEmpty(),
+			"ListMeasure must contain no user-created entries after deletion")
+
+		g.By("Verifying write to deleted measure fails or returns non-SUCCEED status")
+		writeStatus, writeErr := sendSingleMeasureWrite(ctx, clients.MeasureWriteClient, groupName, measureName, 0)
+		// The measure entity is no longer in the routing cache; write must fail.
+		if writeErr == nil {
+			gm.Expect(writeStatus).ShouldNot(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+				"write to deleted measure must not return STATUS_SUCCEED")
+		}
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.6 — delete-then-recreate is idempotent for identical shape.
+	g.It("delete-then-recreate is idempotent for identical shape (§6.6)", func() {
+		groupName := fmt.Sprintf("ar-recreate-%d", time.Now().UnixNano())
+		measureName := applyRollbackMeasureName
+
+		g.By("Creating measure group and measure → R1")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: arMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createResp1, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+		r1 := createResp1.GetModRevision()
+
+		awaitErr1 := clients.AwaitRevision(ctx, r1, 10*time.Second)
+		gm.Expect(awaitErr1).ShouldNot(gm.HaveOccurred())
+
+		g.By("Deleting measure → T_del; awaiting deletion")
+		deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+		tDel := deleteResp.GetDeleteTime()
+		gm.Expect(tDel).Should(gm.BeNumerically(">", int64(0)))
+
+		awaitDelErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+		gm.Expect(awaitDelErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Recreating the same measure spec → R2")
+		createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createErr2).ShouldNot(gm.HaveOccurred())
+		r2 := createResp2.GetModRevision()
+
+		g.By("Awaiting R2")
+		awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+		gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+		g.By("Assertions: ModRevision, R2 > R1, CreatedAt2 > T_del, shape matches, ListMeasure length 1")
+		getResp, getErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr).ShouldNot(gm.HaveOccurred())
+		got := getResp.GetMeasure()
+
+		gm.Expect(got.GetMetadata().GetModRevision()).Should(gm.Equal(r2), "ModRevision must equal R2")
+		gm.Expect(r2).Should(gm.BeNumerically(">", r1), "R2 must be > R1")
+		gm.Expect(got.GetCreatedAt()).ShouldNot(gm.BeNil())
+		gm.Expect(got.GetCreatedAt().AsTime().UnixNano()).Should(
+			gm.BeNumerically(">", tDel),
+			"CreatedAt2 must be after T_del (tombstone invariant Step 1.3)",
+		)
+		gm.Expect(got.GetEntity().GetTagNames()).Should(gm.Equal([]string{"host"}), "entity must match original shape")
+
+		listResp, listErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(userMeasures(listResp.GetMeasure())).Should(gm.HaveLen(1),
+			"ListMeasure must return exactly one user-created entry after recreate")
+
+		g.By("Write with stale R1 must return STATUS_EXPIRED_SCHEMA")
+		writeStatus, writeErr := sendSingleMeasureWrite(ctx, clients.MeasureWriteClient, groupName, measureName, r1)
+		gm.Expect(writeErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(writeStatus).Should(gm.Equal(modelv1.Status_STATUS_EXPIRED_SCHEMA.String()),
+			"write with stale ModRevision R1 must return STATUS_EXPIRED_SCHEMA after recreate")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.7 — produces no measures when nothing is created.
+	g.It("produces no measures when nothing is created (§6.7)", func() {
+		groupName := fmt.Sprintf("ar-empty-%d", time.Now().UnixNano())
+
+		g.By("Creating group only — no measures")
+		createGroupResp, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: arMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+		groupRev := createGroupResp.GetModRevision()
+		awaitGroupErr := clients.AwaitRevision(ctx, groupRev, 10*time.Second)
+		gm.Expect(awaitGroupErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("All list RPCs must return no user-created entries")
+		listMeasureResp, listMeasureErr := clients.MeasureRegClient.List(ctx, &databasev1.MeasureRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listMeasureErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(userMeasures(listMeasureResp.GetMeasure())).Should(gm.BeEmpty(),
+			"ListMeasure must contain no user-created entries")
+
+		listStreamResp, listStreamErr := clients.StreamRegClient.List(ctx, &databasev1.StreamRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listStreamErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listStreamResp.GetStream()).Should(gm.BeEmpty(), "ListStream must be empty")
+
+		listTraceResp, listTraceErr := clients.TraceRegClient.List(ctx, &databasev1.TraceRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listTraceErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listTraceResp.GetTrace()).Should(gm.BeEmpty(), "ListTrace must be empty")
+
+		listIdxRuleResp, listIdxRuleErr := clients.IndexRuleClient.List(ctx, &databasev1.IndexRuleRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listIdxRuleErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listIdxRuleResp.GetIndexRule()).Should(gm.BeEmpty(), "ListIndexRule must be empty")
+
+		listIdxBindResp, listIdxBindErr := clients.IndexRuleBindingClient.List(ctx, &databasev1.IndexRuleBindingRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listIdxBindErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listIdxBindResp.GetIndexRuleBinding()).Should(gm.BeEmpty(), "ListIndexRuleBinding must be empty")
+
+		listTopNResp, listTopNErr := clients.TopNAggregationRegClient.List(ctx, &databasev1.TopNAggregationRegistryServiceListRequest{Group: groupName})
+		gm.Expect(listTopNErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(listTopNResp.GetTopNAggregation()).Should(gm.BeEmpty(), "ListTopNAggregation must be empty")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+})
diff --git a/test/cases/schema/clamp.go b/test/cases/schema/clamp.go
new file mode 100644
index 000000000..12d0ce58e
--- /dev/null
+++ b/test/cases/schema/clamp.go
@@ -0,0 +1,313 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+	"github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// sendStreamQueryWithRange issues a unary stream Query with an explicit time range.
+// It returns the QueryResponse or an error; callers inspect Elements / GroupStatuses.
+// The query carries GroupModRevisions so the schema-aware clamp activates — without
+// it the liaison treats the request as a legacy query and skips clamping.
+func sendStreamQueryWithRange(
+	ctx context.Context,
+	client streamv1.StreamServiceClient,
+	groupName, streamName string,
+	begin, end time.Time,
+	modRevision int64,
+) (*streamv1.QueryResponse, error) {
+	return client.Query(ctx, &streamv1.QueryRequest{
+		Groups: []string{groupName},
+		Name:   streamName,
+		TimeRange: &modelv1.TimeRange{
+			Begin: timestamppb.New(begin.Truncate(time.Millisecond)),
+			End:   timestamppb.New(end.Truncate(time.Millisecond)),
+		},
+		Limit:             10,
+		GroupModRevisions: map[string]int64{groupName: modRevision},
+		Projection: &modelv1.TagProjection{
+			TagFamilies: []*modelv1.TagProjection_TagFamily{
+				{Name: "default", Tags: []string{"svc"}},
+			},
+		},
+	})
+}
+
+// Schema time-range clamp smoke tests — Rule 7 / §4.6.
+// §4.6.4 writes real data and verifies no pre-creation leakage.
+// Each spec exercises a distinct outcome of clamping the query TimeRange.Begin to
+// the maximum schema.CreatedAt across all queried groups.
+var _ = g.Describe("Schema time-range clamp", func() {
+	var (
+		ctx     context.Context
+		clients *Clients
+	)
+
+	g.BeforeEach(func() {
+		ctx = context.Background()
+		clients = NewClients(SharedContext.Connection)
+	})
+
+	// §4.6.1: when both Begin and End fall before the schema's CreatedAt, the server
+	// clamps Begin forward to CreatedAt; since clamped Begin > End the response is
+	// immediately empty (no data can exist in the resulting range).
+	g.It("returns empty result when query End is before schema CreatedAt (§4.6.1)", func() {
+		groupName := fmt.Sprintf("clamp-past-%d", time.Now().UnixNano())
+		streamName := "clamp_stream"
+
+		g.By("Creating stream group and schema")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: wgStreamGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createStreamResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+			Stream: wgStreamSpec(groupName, streamName),
+		})
+		gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+		streamRev := createStreamResp.GetModRevision()
+
+		g.By("Waiting for schema to be applied in cache (so CreatedAt is populated)")
+		awaitErr := clients.AwaitApplied(ctx, []string{fmt.Sprintf("stream:%s/%s", groupName, streamName)}, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Querying with a range that ends far in the past (before schema creation)")
+		epoch := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+		resp, queryErr := sendStreamQueryWithRange(ctx, clients.StreamWriteClient, groupName, streamName,
+			epoch, epoch.Add(time.Millisecond), streamRev)
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(resp.GetElements()).Should(gm.BeEmpty(),
+			"clamped Begin (≈ CreatedAt) > End (epoch+1ms) must produce an empty response")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §4.6.2: when Begin is before schema.CreatedAt but End is in the future, the
+	// server clamps Begin forward to CreatedAt and the query executes successfully.
+	// Since no data was written the response has zero elements but no error.
+	g.It("succeeds and returns zero elements when query spans schema CreatedAt (§4.6.2)", func() {
+		groupName := fmt.Sprintf("clamp-span-%d", time.Now().UnixNano())
+		streamName := "clamp_stream"
+
+		g.By("Creating stream group and schema")
+		groupResp, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: wgStreamGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createStreamResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+			Stream: wgStreamSpec(groupName, streamName),
+		})
+		gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+		streamRev := createStreamResp.GetModRevision()
+
+		g.By("Waiting for group and stream schema to be applied in cache")
+		gm.Expect(clients.AwaitRevision(ctx, groupResp.GetModRevision(), 10*time.Second)).ShouldNot(gm.HaveOccurred())
+		awaitErr := clients.AwaitApplied(ctx, []string{fmt.Sprintf("stream:%s/%s", groupName, streamName)}, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Querying with Begin in the far past and End in the near future")
+		epoch := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+		now := time.Now()
+		resp, queryErr := sendStreamQueryWithRange(ctx, clients.StreamWriteClient, groupName, streamName,
+			epoch, now.Add(time.Hour), streamRev)
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred(),
+			"query spanning schema CreatedAt must succeed after Begin is clamped")
+		// No data was written; expect zero elements (not an error).
+		gm.Expect(resp.GetElements()).Should(gm.BeEmpty(),
+			"no data was written so zero elements are expected")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §4.6.3: when multiple groups are queried and each has a different CreatedAt,
+	// Begin is clamped to the maximum CreatedAt across all groups.
+	// An End in the far past produces an empty response.
+	g.It("clamps to the maximum CreatedAt across multiple groups (§4.6.3)", func() {
+		group1 := fmt.Sprintf("clamp-multi1-%d", time.Now().UnixNano())
+		group2 := fmt.Sprintf("clamp-multi2-%d", time.Now().UnixNano())
+		streamName := "clamp_stream"
+
+		g.By("Creating two stream groups and schemas back-to-back")
+		streamRevs := make(map[string]int64, 2)
+		for _, groupName := range []string{group1, group2} {
+			_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+				Group: wgStreamGroup(groupName),
+			})
+			gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+			createStreamResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+				Stream: wgStreamSpec(groupName, streamName),
+			})
+			gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+			streamRevs[groupName] = createStreamResp.GetModRevision()
+		}
+
+		g.By("Waiting for both schemas to be applied")
+		awaitErr := clients.AwaitApplied(ctx, []string{
+			fmt.Sprintf("stream:%s/%s", group1, streamName),
+			fmt.Sprintf("stream:%s/%s", group2, streamName),
+		}, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Querying both groups with a range that ends far in the past")
+		epoch := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+		resp, queryErr := clients.StreamWriteClient.Query(ctx, &streamv1.QueryRequest{
+			Groups: []string{group1, group2},
+			Name:   streamName,
+			TimeRange: &modelv1.TimeRange{
+				Begin: timestamppb.New(epoch),
+				End:   timestamppb.New(epoch.Add(time.Millisecond)),
+			},
+			Limit:             10,
+			GroupModRevisions: streamRevs,
+			Projection: &modelv1.TagProjection{
+				TagFamilies: []*modelv1.TagProjection_TagFamily{
+					{Name: "default", Tags: []string{"svc"}},
+				},
+			},
+		})
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(resp.GetElements()).Should(gm.BeEmpty(),
+			"max CreatedAt across both groups > epoch+1ms so the clamped range must be empty")
+
+		for _, groupName := range []string{group1, group2} {
+			_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{
+				Group: groupName,
+			})
+		}
+	})
+
+	// §4.6.4: PRIMARY Rule 7 correctness test — falsifies the unclamped case.
+	// Writes one datum at T_data1 in group1 (older CreatedAt1), then creates group2
+	// with CreatedAt2 > T_data1. Querying both groups with a wide Begin must return
+	// ZERO data points: the clamp forwards Begin to max(CreatedAt1, CreatedAt2) =
+	// CreatedAt2 > T_data1, so the datum is excluded. Without clamp, T_data1 falls
+	// inside [Begin, End] and the datum would leak — proving the clamp is actually
+	// applied rather than merely consistent with an already-in-range write.
+	g.It("clips TimeRange.Begin to max(CreatedAt) and excludes pre-creation data (§4.6.4)", func() {
+		// TODO(phase-2): Phase 1 AwaitRevisionApplied is liaison-only by design. This spec's
+		// baseline sanity check (Create → AwaitRevision → Write → Query expecting HaveLen(1))
+		// races the data-node tsTable readiness in distributed mode. The actual clamp
+		// falsification is sound; only the prerequisite Write→Query round-trip flakes.
+		// Cluster-wide barrier semantics ship in Phase 2 via NodeSchemaStatusService +
+		// liaison fan-out (plan Steps 2.1–2.2); re-enable this spec in distributed mode
+		// once those land.
+		if SharedContext.Mode == "distributed" {
+			g.Skip("§4.6.4 requires cluster-wide propagation barrier (Phase 2)")
+		}
+		group1 := fmt.Sprintf("clamp-leak1-%d", time.Now().UnixNano())
+		group2 := fmt.Sprintf("clamp-leak2-%d", time.Now().UnixNano())
+		measureName := "clamp_measure"
+
+		g.By("Creating older group1 and measure (CreatedAt1)")
+		_, createGroup1Err := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: shapeBreakMeasureGroup(group1),
+		})
+		gm.Expect(createGroup1Err).ShouldNot(gm.HaveOccurred())
+
+		createResp1, createMeasure1Err := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: throughputMeasureSpecV1(group1, measureName),
+		})
+		gm.Expect(createMeasure1Err).ShouldNot(gm.HaveOccurred())
+		r1 := createResp1.GetModRevision()
+		gm.Expect(clients.AwaitRevision(ctx, r1, 10*time.Second)).ShouldNot(gm.HaveOccurred())
+
+		g.By("Writing one data point at T_data1 in measure1 (strictly after CreatedAt1, strictly before CreatedAt2)")
+		tData1 := timestamp.NowMilli()
+		hostTags := []*modelv1.TagFamilyForWrite{{
+			Tags: []*modelv1.TagValue{{
+				Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}},
+			}},
+		}}
+		writeStatus, writeErr := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, group1, measureName, tData1, r1, hostTags)
+		gm.Expect(writeErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(writeStatus).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+			"write at T_data1 must return STATUS_SUCCEED")
+
+		g.By("Sanity-checking that querying group1 alone returns the datum (legacy unclamped baseline — pass modRevision=0)")
+		baselineResp, baselineErr := queryMeasureRange(ctx, clients.MeasureWriteClient, group1, measureName,
+			tData1.Add(-time.Hour), time.Now().Add(time.Hour), 0)
+		gm.Expect(baselineErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(baselineResp.GetDataPoints()).Should(gm.HaveLen(1),
+			"baseline single-group query must return the written datum — otherwise §4.6.4 is not falsifying anything")
+
+		g.By("Creating newer group2 and measure (CreatedAt2 > T_data1)")
+		_, createGroup2Err := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: shapeBreakMeasureGroup(group2),
+		})
+		gm.Expect(createGroup2Err).ShouldNot(gm.HaveOccurred())
+
+		createResp2, createMeasure2Err := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: throughputMeasureSpecV1(group2, measureName),
+		})
+		gm.Expect(createMeasure2Err).ShouldNot(gm.HaveOccurred())
+		r2 := createResp2.GetModRevision()
+		gm.Expect(clients.AwaitRevision(ctx, r2, 10*time.Second)).ShouldNot(gm.HaveOccurred())
+
+		g.By("Verifying CreatedAt2 > T_data1 so the clamp falsification is meaningful")
+		getResp2, getMeasure2Err := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: group2},
+		})
+		gm.Expect(getMeasure2Err).ShouldNot(gm.HaveOccurred())
+		createdAt2 := getResp2.GetMeasure().GetCreatedAt()
+		gm.Expect(createdAt2).ShouldNot(gm.BeNil())
+		gm.Expect(createdAt2.AsTime().After(tData1)).Should(gm.BeTrue(),
+			"CreatedAt2 must be strictly after T_data1 for the falsification to be meaningful")
+
+		g.By("Querying group1+group2 with Begin far before T_data1 — clamp forwards Begin to CreatedAt2 > T_data1, excluding the datum")
+		queryResp, queryErr := clients.MeasureWriteClient.Query(ctx, &measurev1.QueryRequest{
+			Groups: []string{group1, group2},
+			Name:   measureName,
+			TimeRange: &modelv1.TimeRange{
+				Begin: timestamppb.New(tData1.Add(-time.Hour).Truncate(time.Millisecond)),
+				End:   timestamppb.New(time.Now().Add(time.Hour).Truncate(time.Millisecond)),
+			},
+			GroupModRevisions: map[string]int64{group1: r1, group2: r2},
+			TagProjection: &modelv1.TagProjection{
+				TagFamilies: []*modelv1.TagProjection_TagFamily{
+					{Name: "default", Tags: []string{"host"}},
+				},
+			},
+			FieldProjection: &measurev1.QueryRequest_FieldProjection{
+				Names: []string{"value"},
+			},
+			Limit: 100,
+		})
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(queryResp.GetDataPoints()).Should(gm.BeEmpty(),
+			"Rule 7 clamp invariant: T_data1 < CreatedAt2 must be excluded by the clamp; without clamp the datum would leak")
+
+		for _, groupName := range []string{group1, group2} {
+			_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+		}
+	})
+})
diff --git a/test/cases/schema/clients.go b/test/cases/schema/clients.go
new file mode 100644
index 000000000..b7aa6a3e0
--- /dev/null
+++ b/test/cases/schema/clients.go
@@ -0,0 +1,176 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"google.golang.org/grpc"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	schemav1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/schema/v1"
+	streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+	tracev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/trace/v1"
+)
+
+// Clients holds all gRPC clients required by the schema integration test helpers.
+type Clients struct {
+	GroupClient              databasev1.GroupRegistryServiceClient
+	MeasureRegClient         databasev1.MeasureRegistryServiceClient
+	StreamRegClient          databasev1.StreamRegistryServiceClient
+	TraceRegClient           databasev1.TraceRegistryServiceClient
+	IndexRuleClient          databasev1.IndexRuleRegistryServiceClient
+	IndexRuleBindingClient   databasev1.IndexRuleBindingRegistryServiceClient
+	TopNAggregationRegClient databasev1.TopNAggregationRegistryServiceClient
+	MeasureWriteClient       measurev1.MeasureServiceClient
+	StreamWriteClient        streamv1.StreamServiceClient
+	TraceWriteClient         tracev1.TraceServiceClient
+	BarrierClient            schemav1.SchemaBarrierServiceClient
+}
+
+// NewClients constructs the registry/service client bundle used by the schema integration tests.
+func NewClients(conn *grpc.ClientConn) *Clients {
+	return &Clients{
+		GroupClient:              databasev1.NewGroupRegistryServiceClient(conn),
+		MeasureRegClient:         databasev1.NewMeasureRegistryServiceClient(conn),
+		StreamRegClient:          databasev1.NewStreamRegistryServiceClient(conn),
+		TraceRegClient:           databasev1.NewTraceRegistryServiceClient(conn),
+		IndexRuleClient:          databasev1.NewIndexRuleRegistryServiceClient(conn),
+		IndexRuleBindingClient:   databasev1.NewIndexRuleBindingRegistryServiceClient(conn),
+		TopNAggregationRegClient: databasev1.NewTopNAggregationRegistryServiceClient(conn),
+		MeasureWriteClient:       measurev1.NewMeasureServiceClient(conn),
+		StreamWriteClient:        streamv1.NewStreamServiceClient(conn),
+		TraceWriteClient:         tracev1.NewTraceServiceClient(conn),
+		BarrierClient:            schemav1.NewSchemaBarrierServiceClient(conn),
+	}
+}
+
+// AwaitRevision blocks until the cluster's schema cache has advanced to the given
+// mod_revision or the timeout elapses. Delegates to SchemaBarrierService.AwaitRevisionApplied.
+func (c *Clients) AwaitRevision(ctx context.Context, target int64, timeout time.Duration) error {
+	resp, rpcErr := c.BarrierClient.AwaitRevisionApplied(ctx, &schemav1.AwaitRevisionAppliedRequest{
+		MinRevision: target,
+		Timeout:     durationpb.New(timeout),
+	})
+	if rpcErr != nil {
+		return fmt.Errorf("AwaitRevisionApplied RPC failed: %w", rpcErr)
+	}
+	if !resp.GetApplied() {
+		return fmt.Errorf("timeout waiting for schema revision %d", target)
+	}
+	return nil
+}
+
+// AwaitApplied blocks until every key in the list reports as present in the
+// cluster's schema cache or the timeout elapses. Each key is encoded as
+// "kind:group/name"; supported kinds are "measure", "stream", and "trace".
+// Delegates to SchemaBarrierService.AwaitSchemaApplied.
+func (c *Clients) AwaitApplied(ctx context.Context, keys []string, timeout time.Duration) error {
+	schemaKeys, parseErr := parseSchemaKeys(keys)
+	if parseErr != nil {
+		return parseErr
+	}
+	resp, rpcErr := c.BarrierClient.AwaitSchemaApplied(ctx, &schemav1.AwaitSchemaAppliedRequest{
+		Keys:    schemaKeys,
+		Timeout: durationpb.New(timeout),
+	})
+	if rpcErr != nil {
+		return fmt.Errorf("AwaitSchemaApplied RPC failed: %w", rpcErr)
+	}
+	if !resp.GetApplied() {
+		return fmt.Errorf("timeout waiting for schema keys to be applied: %v", keys)
+	}
+	return nil
+}
+
+// AwaitDeleted blocks until every key in the list is absent from the cluster's
+// schema cache or the timeout elapses. Encoding follows AwaitApplied.
+// Delegates to SchemaBarrierService.AwaitSchemaDeleted.
+func (c *Clients) AwaitDeleted(ctx context.Context, keys []string, timeout time.Duration) error {
+	schemaKeys, parseErr := parseSchemaKeys(keys)
+	if parseErr != nil {
+		return parseErr
+	}
+	resp, rpcErr := c.BarrierClient.AwaitSchemaDeleted(ctx, &schemav1.AwaitSchemaDeletedRequest{
+		Keys:    schemaKeys,
+		Timeout: durationpb.New(timeout),
+	})
+	if rpcErr != nil {
+		return fmt.Errorf("AwaitSchemaDeleted RPC failed: %w", rpcErr)
+	}
+	if !resp.GetApplied() {
+		return fmt.Errorf("timeout waiting for schema keys to be deleted: %v", keys)
+	}
+	return nil
+}
+
+// internalTopNResultMeasureName is the auto-created measure that backs TopN
+// aggregations. The measure subsystem provisions it in every measure group, so
+// list assertions in the §6 specs need to skip it.
+const internalTopNResultMeasureName = "_top_n_result"
+
+// userMeasures returns measures with the auto-provisioned _top_n_result entry
+// removed, so spec assertions can count user-created measures only without
+// hard-coding off-by-one for the internal entry.
+func userMeasures(in []*databasev1.Measure) []*databasev1.Measure {
+	out := make([]*databasev1.Measure, 0, len(in))
+	for _, m := range in {
+		if m.GetMetadata().GetName() == internalTopNResultMeasureName {
+			continue
+		}
+		out = append(out, m)
+	}
+	return out
+}
+
+// parseSchemaKeys converts a slice of "kind:group/name" encoded strings to SchemaKey protos.
+func parseSchemaKeys(keys []string) ([]*schemav1.SchemaKey, error) {
+	result := make([]*schemav1.SchemaKey, 0, len(keys))
+	for _, key := range keys {
+		sk, parseErr := parseSchemaKey(key)
+		if parseErr != nil {
+			return nil, parseErr
+		}
+		result = append(result, sk)
+	}
+	return result, nil
+}
+
+// parseSchemaKey splits an encoded "kind:group/name" key into a SchemaKey proto.
+func parseSchemaKey(key string) (*schemav1.SchemaKey, error) {
+	colon := strings.IndexByte(key, ':')
+	if colon <= 0 || colon == len(key)-1 {
+		return nil, fmt.Errorf("invalid schema key %q: expected kind:group/name", key)
+	}
+	kind := key[:colon]
+	rest := key[colon+1:]
+	slash := strings.IndexByte(rest, '/')
+	if slash <= 0 || slash == len(rest)-1 {
+		return nil, fmt.Errorf("invalid schema key %q: expected kind:group/name", key)
+	}
+	return &schemav1.SchemaKey{
+		Kind:  kind,
+		Group: rest[:slash],
+		Name:  rest[slash+1:],
+	}, nil
+}
diff --git a/test/cases/schema/deletion.go b/test/cases/schema/deletion.go
index 21b30d66c..7313e75a0 100644
--- a/test/cases/schema/deletion.go
+++ b/test/cases/schema/deletion.go
@@ -44,19 +44,6 @@ import (
 // SharedContext is set by the test environment (standalone or distributed).
 var SharedContext helpers.SharedContext
 
-// Clients holds all necessary gRPC clients for deletion tests.
-type Clients struct {
-	GroupClient            databasev1.GroupRegistryServiceClient
-	MeasureRegClient       databasev1.MeasureRegistryServiceClient
-	StreamRegClient        databasev1.StreamRegistryServiceClient
-	TraceRegClient         databasev1.TraceRegistryServiceClient
-	IndexRuleClient        databasev1.IndexRuleRegistryServiceClient
-	IndexRuleBindingClient databasev1.IndexRuleBindingRegistryServiceClient
-	MeasureWriteClient     measurev1.MeasureServiceClient
-	StreamWriteClient      streamv1.StreamServiceClient
-	TraceWriteClient       tracev1.TraceServiceClient
-}
-
 // Shared test cases. Automatically registered when this package is imported.
 var _ = g.Describe("Schema deletion", func() {
 	var (
@@ -66,18 +53,7 @@ var _ = g.Describe("Schema deletion", func() {
 
 	g.BeforeEach(func() {
 		ctx = context.Background()
-		conn := SharedContext.Connection
-		clients = &Clients{
-			GroupClient:            databasev1.NewGroupRegistryServiceClient(conn),
-			MeasureRegClient:       databasev1.NewMeasureRegistryServiceClient(conn),
-			StreamRegClient:        databasev1.NewStreamRegistryServiceClient(conn),
-			TraceRegClient:         databasev1.NewTraceRegistryServiceClient(conn),
-			IndexRuleClient:        databasev1.NewIndexRuleRegistryServiceClient(conn),
-			IndexRuleBindingClient: databasev1.NewIndexRuleBindingRegistryServiceClient(conn),
-			MeasureWriteClient:     measurev1.NewMeasureServiceClient(conn),
-			StreamWriteClient:      streamv1.NewStreamServiceClient(conn),
-			TraceWriteClient:       tracev1.NewTraceServiceClient(conn),
-		}
+		clients = NewClients(SharedContext.Connection)
 	})
 
 	g.It("should delete measure correctly", func() {
diff --git a/test/cases/schema/query_gate.go b/test/cases/schema/query_gate.go
new file mode 100644
index 000000000..3b52cbc07
--- /dev/null
+++ b/test/cases/schema/query_gate.go
@@ -0,0 +1,373 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	streamv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/stream/v1"
+	"github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// sendStreamQuery issues a unary stream Query with a single-group GroupModRevisions entry.
+// It returns the per-group status for groupName from the response, or an error.
+func sendStreamQuery(
+	ctx context.Context,
+	client streamv1.StreamServiceClient,
+	groupName, streamName string,
+	modRevision int64,
+) (modelv1.Status, error) {
+	now := timestamp.NowMilli()
+	resp, queryErr := client.Query(ctx, &streamv1.QueryRequest{
+		Groups: []string{groupName},
+		Name:   streamName,
+		TimeRange: &modelv1.TimeRange{
+			Begin: timestamppb.New(now.Add(-time.Hour)),
+			End:   timestamppb.New(now.Add(time.Hour)),
+		},
+		Limit:             1,
+		GroupModRevisions: map[string]int64{groupName: modRevision},
+		Projection: &modelv1.TagProjection{
+			TagFamilies: []*modelv1.TagProjection_TagFamily{
+				{Name: "default", Tags: []string{"svc"}},
+			},
+		},
+	})
+	if queryErr != nil {
+		return modelv1.Status_STATUS_UNSPECIFIED, fmt.Errorf("stream query: %w", queryErr)
+	}
+	st, ok := resp.GetGroupStatuses()[groupName]
+	if !ok {
+		return modelv1.Status_STATUS_UNSPECIFIED, fmt.Errorf("group %q absent from GroupStatuses in response", groupName)
+	}
+	return st, nil
+}
+
+// queryGateStreamName is the fixture stream name reused across §4.5 specs.
+const queryGateStreamName = "qg_stream"
+
+// Schema query gate smoke tests — §4.5.1 / §4.5.2 / §4.5.3.
+// Each spec exercises a distinct branch of the per-group ModRevision gate on the
+// query path.
+var _ = g.Describe("Schema query gate", func() {
+	var (
+		ctx     context.Context
+		clients *Clients
+	)
+
+	g.BeforeEach(func() {
+		ctx = context.Background()
+		clients = NewClients(SharedContext.Connection)
+	})
+
+	// §4.5.1: a query carrying a group ModRevision below the cached schema revision
+	// is short-circuited and returns STATUS_EXPIRED_SCHEMA for that group.
+	g.It("returns STATUS_EXPIRED_SCHEMA for stale group ModRevision (§4.5.1)", func() {
+		groupName := fmt.Sprintf("qg-stale-%d", time.Now().UnixNano())
+		streamName := queryGateStreamName
+
+		g.By("Creating stream group and schema to obtain baseline ModRevision (R1)")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: wgStreamGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+			Stream: wgStreamSpec(groupName, streamName),
+		})
+		gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+		r1 := createResp.GetModRevision()
+		gm.Expect(r1).Should(gm.BeNumerically(">", int64(0)))
+
+		g.By("Updating the stream to advance the cache to R2 > R1")
+		getResp, getStreamErr := clients.StreamRegClient.Get(ctx, &databasev1.StreamRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: streamName, Group: groupName},
+		})
+		gm.Expect(getStreamErr).ShouldNot(gm.HaveOccurred())
+		updatedStream := getResp.GetStream()
+		updatedStream.TagFamilies[0].Tags = append(updatedStream.TagFamilies[0].Tags,
+			&databasev1.TagSpec{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING})
+		updateResp, updateStreamErr := clients.StreamRegClient.Update(ctx, &databasev1.StreamRegistryServiceUpdateRequest{
+			Stream: updatedStream,
+		})
+		gm.Expect(updateStreamErr).ShouldNot(gm.HaveOccurred())
+		r2 := updateResp.GetModRevision()
+		gm.Expect(r2).Should(gm.BeNumerically(">", r1))
+
+		g.By("Waiting for the cache to reach R2")
+		awaitErr := clients.AwaitRevision(ctx, r2, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Sending a query with the old ModRevision R1 (stale)")
+		st, queryErr := sendStreamQuery(ctx, clients.StreamWriteClient, groupName, streamName, r1)
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(st).Should(gm.Equal(modelv1.Status_STATUS_EXPIRED_SCHEMA),
+			"query with group ModRevision < cache must return STATUS_EXPIRED_SCHEMA")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §4.5.2: a query carrying a group ModRevision far ahead of the cache is held
+	// until the configured wait duration and then returned with STATUS_SCHEMA_NOT_APPLIED.
+	g.It("returns STATUS_SCHEMA_NOT_APPLIED for ahead group ModRevision that never applies (§4.5.2)", func() {
+		groupName := fmt.Sprintf("qg-ahead-%d", time.Now().UnixNano())
+		streamName := queryGateStreamName
+
+		g.By("Creating stream group and schema")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: wgStreamGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+			Stream: wgStreamSpec(groupName, streamName),
+		})
+		gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+		r1 := createResp.GetModRevision()
+
+		g.By("Waiting for the schema to be applied in cache")
+		awaitErr := clients.AwaitApplied(ctx, []string{fmt.Sprintf("stream:%s/%s", groupName, streamName)}, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Sending a query with ModRevision = R1 + 99999 (far ahead, will never apply)")
+		aheadRev := r1 + 99999
+		st, queryErr := sendStreamQuery(ctx, clients.StreamWriteClient, groupName, streamName, aheadRev)
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(st).Should(gm.Equal(modelv1.Status_STATUS_SCHEMA_NOT_APPLIED),
+			"query with group ModRevision > cache must return STATUS_SCHEMA_NOT_APPLIED after wait timeout")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §4.5.3: a query whose group ModRevision matches the cached revision passes the gate
+	// and returns STATUS_SUCCEED for that group.
+	g.It("passes the gate and returns STATUS_SUCCEED when ModRevision matches cache (§4.5.3)", func() {
+		groupName := fmt.Sprintf("qg-match-%d", time.Now().UnixNano())
+		streamName := queryGateStreamName
+
+		g.By("Creating stream group and schema")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: wgStreamGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createResp, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+			Stream: wgStreamSpec(groupName, streamName),
+		})
+		gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+		r1 := createResp.GetModRevision()
+
+		g.By("Waiting for the schema to be applied in cache")
+		awaitErr := clients.AwaitApplied(ctx, []string{fmt.Sprintf("stream:%s/%s", groupName, streamName)}, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Sending a query with the current ModRevision R1 (matching cache)")
+		st, queryErr := sendStreamQuery(ctx, clients.StreamWriteClient, groupName, streamName, r1)
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(st).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED),
+			"query with group ModRevision equal to cache must pass the gate with STATUS_SUCCEED")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §4.5.4: mixed groups — one at current rev (SUCCEED), one stale (EXPIRED_SCHEMA);
+	// query short-circuits and returns empty elements.
+	g.It("returns mixed group_statuses and empty elements when one group is stale (§4.5.4)", func() {
+		group1 := fmt.Sprintf("qg-mixed1-%d", time.Now().UnixNano())
+		group2 := fmt.Sprintf("qg-mixed2-%d", time.Now().UnixNano())
+		streamName := queryGateStreamName
+
+		g.By("Creating group1 + stream → R1a; updating stream → R1b")
+		_, createGroup1Err := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: wgStreamGroup(group1),
+		})
+		gm.Expect(createGroup1Err).ShouldNot(gm.HaveOccurred())
+
+		createResp1a, createStream1Err := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+			Stream: wgStreamSpec(group1, streamName),
+		})
+		gm.Expect(createStream1Err).ShouldNot(gm.HaveOccurred())
+		r1a := createResp1a.GetModRevision()
+
+		getResp1, getStream1Err := clients.StreamRegClient.Get(ctx, &databasev1.StreamRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: streamName, Group: group1},
+		})
+		gm.Expect(getStream1Err).ShouldNot(gm.HaveOccurred())
+		updatedStream1 := getResp1.GetStream()
+		updatedStream1.TagFamilies[0].Tags = append(updatedStream1.TagFamilies[0].Tags,
+			&databasev1.TagSpec{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING})
+		updateResp1, updateStream1Err := clients.StreamRegClient.Update(ctx, &databasev1.StreamRegistryServiceUpdateRequest{
+			Stream: updatedStream1,
+		})
+		gm.Expect(updateStream1Err).ShouldNot(gm.HaveOccurred())
+		r1b := updateResp1.GetModRevision()
+		gm.Expect(r1b).Should(gm.BeNumerically(">", r1a))
+
+		g.By("Creating group2 + stream → R2")
+		_, createGroup2Err := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: wgStreamGroup(group2),
+		})
+		gm.Expect(createGroup2Err).ShouldNot(gm.HaveOccurred())
+
+		createResp2, createStream2Err := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+			Stream: wgStreamSpec(group2, streamName),
+		})
+		gm.Expect(createStream2Err).ShouldNot(gm.HaveOccurred())
+		r2 := createResp2.GetModRevision()
+
+		g.By("Awaiting max revision")
+		maxRev := r1b
+		if r2 > maxRev {
+			maxRev = r2
+		}
+		awaitErr := clients.AwaitRevision(ctx, maxRev, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Querying both groups with group1 stale (R1a) and group2 current (R2)")
+		now := timestamp.NowMilli()
+		resp, queryErr := clients.StreamWriteClient.Query(ctx, &streamv1.QueryRequest{
+			Groups: []string{group1, group2},
+			Name:   streamName,
+			TimeRange: &modelv1.TimeRange{
+				Begin: timestamppb.New(now.Add(-time.Hour)),
+				End:   timestamppb.New(now.Add(time.Hour)),
+			},
+			Limit: 10,
+			GroupModRevisions: map[string]int64{
+				group1: r1a,
+				group2: r2,
+			},
+			Projection: &modelv1.TagProjection{
+				TagFamilies: []*modelv1.TagProjection_TagFamily{
+					{Name: "default", Tags: []string{"svc"}},
+				},
+			},
+		})
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Asserting group1 status is EXPIRED_SCHEMA and group2 status is SUCCEED")
+		statuses := resp.GetGroupStatuses()
+		gm.Expect(statuses).ShouldNot(gm.BeNil())
+		gm.Expect(statuses[group1]).Should(gm.Equal(modelv1.Status_STATUS_EXPIRED_SCHEMA),
+			"group1 with stale R1a must be STATUS_EXPIRED_SCHEMA")
+		// group2 is current; may be SUCCEED or absent when short-circuited — check actual behavior.
+		if st2, ok := statuses[group2]; ok {
+			gm.Expect(st2).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED),
+				"group2 with current R2 must be STATUS_SUCCEED when present")
+		}
+
+		g.By("Query short-circuits: elements must be empty")
+		gm.Expect(resp.GetElements()).Should(gm.BeEmpty(),
+			"query short-circuits on EXPIRED_SCHEMA; elements must be empty")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: group1})
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: group2})
+	})
+
+	// §4.5.5: partial coverage opt-in — group2 not in GroupModRevisions map → ungated.
+	// Query executes; group1 gated (SUCCEED); group2 either absent or SUCCEED in statuses.
+	g.It("executes query when one group is ungated (not in GroupModRevisions) (§4.5.5)", func() {
+		group1 := fmt.Sprintf("qg-partial1-%d", time.Now().UnixNano())
+		group2 := fmt.Sprintf("qg-partial2-%d", time.Now().UnixNano())
+		streamName := queryGateStreamName
+
+		g.By("Creating group1 and group2 with streams")
+		var maxGroupRev int64
+		for _, grpName := range []string{group1, group2} {
+			grpResp, createGrpErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+				Group: wgStreamGroup(grpName),
+			})
+			gm.Expect(createGrpErr).ShouldNot(gm.HaveOccurred())
+			if grpResp.GetModRevision() > maxGroupRev {
+				maxGroupRev = grpResp.GetModRevision()
+			}
+
+			_, createStreamErr := clients.StreamRegClient.Create(ctx, &databasev1.StreamRegistryServiceCreateRequest{
+				Stream: wgStreamSpec(grpName, streamName),
+			})
+			gm.Expect(createStreamErr).ShouldNot(gm.HaveOccurred())
+		}
+
+		g.By("Waiting for both groups and both stream schemas to be applied")
+		gm.Expect(clients.AwaitRevision(ctx, maxGroupRev, 10*time.Second)).ShouldNot(gm.HaveOccurred())
+		awaitErr := clients.AwaitApplied(ctx, []string{
+			fmt.Sprintf("stream:%s/%s", group1, streamName),
+			fmt.Sprintf("stream:%s/%s", group2, streamName),
+		}, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Getting current ModRevision for group1")
+		getResp1, getStream1Err := clients.StreamRegClient.Get(ctx, &databasev1.StreamRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: streamName, Group: group1},
+		})
+		gm.Expect(getStream1Err).ShouldNot(gm.HaveOccurred())
+		r1 := getResp1.GetStream().GetMetadata().GetModRevision()
+		gm.Expect(r1).Should(gm.BeNumerically(">", int64(0)))
+
+		g.By("Querying with group1 gated (current rev) and group2 NOT in GroupModRevisions")
+		now := timestamp.NowMilli()
+		resp, queryErr := clients.StreamWriteClient.Query(ctx, &streamv1.QueryRequest{
+			Groups: []string{group1, group2},
+			Name:   streamName,
+			TimeRange: &modelv1.TimeRange{
+				Begin: timestamppb.New(now.Add(-time.Hour)),
+				End:   timestamppb.New(now.Add(time.Hour)),
+			},
+			Limit: 10,
+			GroupModRevisions: map[string]int64{
+				group1: r1,
+				// group2 intentionally absent — ungated
+			},
+			Projection: &modelv1.TagProjection{
+				TagFamilies: []*modelv1.TagProjection_TagFamily{
+					{Name: "default", Tags: []string{"svc"}},
+				},
+			},
+		})
+		gm.Expect(queryErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Asserting group1 status is SUCCEED and group2 is absent or SUCCEED (ungated)")
+		statuses := resp.GetGroupStatuses()
+		if statuses != nil {
+			if st1, ok := statuses[group1]; ok {
+				gm.Expect(st1).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED),
+					"group1 with current ModRevision must be STATUS_SUCCEED")
+			}
+			// group2 is ungated: its status is either absent from the map or SUCCEED — both are valid.
+			if st2, ok := statuses[group2]; ok {
+				gm.Expect(st2).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED),
+					"group2 when present in statuses must be STATUS_SUCCEED (ungated)")
+			}
+		}
+
+		g.By("Query is not short-circuited (no EXPIRED_SCHEMA in group1)")
+		// No assertion on elements count since no data was written; just verify no error.
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: group1})
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: group2})
+	})
+})
diff --git a/test/cases/schema/shape_break.go b/test/cases/schema/shape_break.go
new file mode 100644
index 000000000..3284579da
--- /dev/null
+++ b/test/cases/schema/shape_break.go
@@ -0,0 +1,523 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package schema
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+	measurev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/measure/v1"
+	modelv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/model/v1"
+	"github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// shapeBreakMeasureGroup creates a CATALOG_MEASURE group with the given name.
+func shapeBreakMeasureGroup(name string) *commonv1.Group {
+	return &commonv1.Group{
+		Metadata: &commonv1.Metadata{Name: name},
+		Catalog:  commonv1.Catalog_CATALOG_MEASURE,
+		ResourceOpts: &commonv1.ResourceOpts{
+			ShardNum:        2,
+			SegmentInterval: &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 1},
+			Ttl:             &commonv1.IntervalRule{Unit: commonv1.IntervalRule_UNIT_DAY, Num: 7},
+		},
+	}
+}
+
+// shapeBreakMeasureSpec returns a Measure with entity=[svc] and both svc/host tags defined,
+// so that an update changing entity to [svc, host] passes validate.Measure but fails
+// validateMeasureUpdate (entity changed).
+func shapeBreakMeasureSpec(group, name string) *databasev1.Measure {
+	return &databasev1.Measure{
+		Metadata: &commonv1.Metadata{Name: name, Group: group},
+		Entity:   &databasev1.Entity{TagNames: []string{"svc"}},
+		TagFamilies: []*databasev1.TagFamilySpec{{
+			Name: "default",
+			Tags: []*databasev1.TagSpec{
+				{Name: "svc", Type: databasev1.TagType_TAG_TYPE_STRING},
+				{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+			},
+		}},
+		Fields: []*databasev1.FieldSpec{{
+			Name:              "value",
+			FieldType:         databasev1.FieldType_FIELD_TYPE_INT,
+			EncodingMethod:    databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+			CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+		}},
+	}
+}
+
+// throughputMeasureSpecV1 returns a Measure for the throughput tests with entity=[host].
+func throughputMeasureSpecV1(group, name string) *databasev1.Measure {
+	return &databasev1.Measure{
+		Metadata: &commonv1.Metadata{Name: name, Group: group},
+		Entity:   &databasev1.Entity{TagNames: []string{"host"}},
+		TagFamilies: []*databasev1.TagFamilySpec{{
+			Name: "default",
+			Tags: []*databasev1.TagSpec{
+				{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+			},
+		}},
+		Fields: []*databasev1.FieldSpec{{
+			Name:              "value",
+			FieldType:         databasev1.FieldType_FIELD_TYPE_INT,
+			EncodingMethod:    databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+			CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+		}},
+	}
+}
+
+// throughputMeasureSpecV2 returns a Measure with entity=[host, instance] (shape-break spec).
+func throughputMeasureSpecV2(group, name string) *databasev1.Measure {
+	return &databasev1.Measure{
+		Metadata: &commonv1.Metadata{Name: name, Group: group},
+		Entity:   &databasev1.Entity{TagNames: []string{"host", "instance"}},
+		TagFamilies: []*databasev1.TagFamilySpec{{
+			Name: "default",
+			Tags: []*databasev1.TagSpec{
+				{Name: "host", Type: databasev1.TagType_TAG_TYPE_STRING},
+				{Name: "instance", Type: databasev1.TagType_TAG_TYPE_STRING},
+			},
+		}},
+		Fields: []*databasev1.FieldSpec{{
+			Name:              "value",
+			FieldType:         databasev1.FieldType_FIELD_TYPE_INT,
+			EncodingMethod:    databasev1.EncodingMethod_ENCODING_METHOD_GORILLA,
+			CompressionMethod: databasev1.CompressionMethod_COMPRESSION_METHOD_ZSTD,
+		}},
+	}
+}
+
+// queryMeasureRange issues a unary measure Query over the given time range.
+// modRevision opts the request into the schema-aware path: the liaison clamps
+// TimeRange.Begin to schema.CreatedAt only when GroupModRevisions is non-empty.
+// Pass 0 to keep the query in the legacy unclamped mode.
+func queryMeasureRange(
+	ctx context.Context,
+	client measurev1.MeasureServiceClient,
+	groupName, measureName string,
+	begin, end time.Time,
+	modRevision int64,
+) (*measurev1.QueryResponse, error) {
+	req := &measurev1.QueryRequest{
+		Groups: []string{groupName},
+		Name:   measureName,
+		TimeRange: &modelv1.TimeRange{
+			Begin: timestamppb.New(begin.Truncate(time.Millisecond)),
+			End:   timestamppb.New(end.Truncate(time.Millisecond)),
+		},
+		TagProjection: &modelv1.TagProjection{
+			TagFamilies: []*modelv1.TagProjection_TagFamily{
+				{Name: "default", Tags: []string{"host"}},
+			},
+		},
+		FieldProjection: &measurev1.QueryRequest_FieldProjection{
+			Names: []string{"value"},
+		},
+		Limit: 100,
+	}
+	if modRevision > 0 {
+		req.GroupModRevisions = map[string]int64{groupName: modRevision}
+	}
+	return client.Query(ctx, req)
+}
+
+// §6.8 / §6.9 / §6.10 / §6.11 — Shape-break and delete-then-recreate scenarios.
+var _ = g.Describe("Schema shape-break rejection", func() {
+	var (
+		ctx     context.Context
+		clients *Clients
+	)
+
+	g.BeforeEach(func() {
+		ctx = context.Background()
+		clients = NewClients(SharedContext.Connection)
+	})
+
+	// §6.8: shape-break — delete+apply new shape creates the new measure (Rule 7 clamp end-to-end).
+	g.It("shape-break: delete+apply new shape creates the new measure (§6.8)", func() {
+		// TODO(phase-2): Phase 1 AwaitRevisionApplied is liaison-only by design. Both this spec
+		// and §6.11 perform an end-to-end data Write+Query round-trip through the liaison after
+		// a schema mutation; in distributed mode the data node can lag the liaison briefly on
+		// tsTable readiness or query-side index refresh, racing the immediate Write→Query.
+		// Cluster-wide barrier semantics ship in Phase 2 via NodeSchemaStatusService + liaison
+		// fan-out (plan Steps 2.1–2.2); re-enable this spec in distributed mode once those land.
+		if SharedContext.Mode == "distributed" {
+			g.Skip("§6.8 requires cluster-wide propagation barrier (Phase 2)")
+		}
+		groupName := fmt.Sprintf("sb-new-%d", time.Now().UnixNano())
+		measureName := "throughput"
+
+		g.By("Creating measure group")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: shapeBreakMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating measure throughput with entity=[host] → R1")
+		createResp1, createErr1 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: throughputMeasureSpecV1(groupName, measureName),
+		})
+		gm.Expect(createErr1).ShouldNot(gm.HaveOccurred())
+		r1 := createResp1.GetModRevision()
+
+		awaitErr1 := clients.AwaitRevision(ctx, r1, 10*time.Second)
+		gm.Expect(awaitErr1).ShouldNot(gm.HaveOccurred())
+
+		g.By("Capturing CreatedAt1 from GetMeasure")
+		getResp1, getErr1 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr1).ShouldNot(gm.HaveOccurred())
+		createdAt1 := getResp1.GetMeasure().GetCreatedAt()
+		gm.Expect(createdAt1).ShouldNot(gm.BeNil())
+
+		g.By("Writing one data point at T_data1 = CreatedAt1 + 50ms")
+		// T_data1 uses wall-clock now rather than a future offset from CreatedAt1 so
+		// a subsequent recreate stamps CreatedAt2 strictly after this timestamp.
+		tData1 := timestamp.NowMilli()
+		hostTags1 := []*modelv1.TagFamilyForWrite{{
+			Tags: []*modelv1.TagValue{{
+				Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}},
+			}},
+		}}
+		writeStatus1, writeErr1 := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName, tData1, r1, hostTags1)
+		gm.Expect(writeErr1).ShouldNot(gm.HaveOccurred())
+		gm.Expect(writeStatus1).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+			"initial write must return STATUS_SUCCEED")
+
+		g.By("Querying [CreatedAt1, now+1h] — must return exactly 1 data point")
+		queryResp1, queryErr1 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+			createdAt1.AsTime(), time.Now().Add(time.Hour), r1)
+		gm.Expect(queryErr1).ShouldNot(gm.HaveOccurred())
+		gm.Expect(queryResp1.GetDataPoints()).Should(gm.HaveLen(1), "sanity baseline: pre-delete query must return 1 data point")
+
+		g.By("Deleting measure → T_del; awaiting deletion")
+		deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+		tDel := deleteResp.GetDeleteTime()
+
+		awaitDelErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+		gm.Expect(awaitDelErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating measure throughput with entity=[host, instance] → R2")
+		createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: throughputMeasureSpecV2(groupName, measureName),
+		})
+		gm.Expect(createErr2).ShouldNot(gm.HaveOccurred())
+		r2 := createResp2.GetModRevision()
+
+		awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+		gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+		g.By("Assertions: new shape at R2, CreatedAt2 after T_del, old data clamped, stale write rejected, new write succeeds")
+		getResp2, getErr2 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr2).ShouldNot(gm.HaveOccurred())
+		got2 := getResp2.GetMeasure()
+
+		gm.Expect(got2.GetEntity().GetTagNames()).Should(gm.Equal([]string{"host", "instance"}),
+			"new shape must have entity=[host, instance]")
+		gm.Expect(got2.GetMetadata().GetModRevision()).Should(gm.Equal(r2), "ModRevision must equal R2")
+		gm.Expect(got2.GetCreatedAt()).ShouldNot(gm.BeNil())
+		createdAt2 := got2.GetCreatedAt()
+		gm.Expect(createdAt2.AsTime().After(createdAt1.AsTime())).Should(gm.BeTrue(),
+			"CreatedAt2 must be after CreatedAt1")
+		gm.Expect(createdAt2.AsTime().UnixNano()).Should(gm.BeNumerically(">", tDel),
+			"CreatedAt2 must be after T_del")
+
+		// Rule 7 clamp: query [T_data1, now+1h] must return empty because T_data1 < CreatedAt2.
+		queryResp2, queryErr2 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+			tData1, time.Now().Add(time.Hour), r2)
+		gm.Expect(queryErr2).ShouldNot(gm.HaveOccurred())
+		gm.Expect(queryResp2.GetDataPoints()).Should(gm.BeEmpty(),
+			"Rule 7 clamp must hide pre-CreatedAt2 data points")
+
+		// Stale write with R1 must be rejected.
+		staleHostTags := []*modelv1.TagFamilyForWrite{{
+			Tags: []*modelv1.TagValue{{
+				Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}},
+			}},
+		}}
+		writeStatusStale, writeErrStale := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName,
+			time.Now(), r1, staleHostTags)
+		gm.Expect(writeErrStale).ShouldNot(gm.HaveOccurred())
+		gm.Expect(writeStatusStale).Should(gm.Equal(modelv1.Status_STATUS_EXPIRED_SCHEMA.String()),
+			"write with stale R1 must return STATUS_EXPIRED_SCHEMA")
+
+		// Write with R2 and new tag layout must succeed.
+		newTags := []*modelv1.TagFamilyForWrite{{
+			Tags: []*modelv1.TagValue{
+				{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}}},
+				{Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "inst1"}}},
+			},
+		}}
+		writeStatusNew, writeErrNew := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName,
+			time.Now(), r2, newTags)
+		gm.Expect(writeErrNew).ShouldNot(gm.HaveOccurred())
+		gm.Expect(writeStatusNew).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+			"write with R2 and new tag layout must return STATUS_SUCCEED")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.9: entity-change update is rejected; the error mentions "entity"; ModRevision is unchanged.
+	g.It("rejects a measure entity-change update and leaves ModRevision unchanged (§6.9)", func() {
+		groupName := fmt.Sprintf("sb1-measure-%d", time.Now().UnixNano())
+		measureName := "sb1_measure"
+
+		g.By("Creating measure group")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: shapeBreakMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating measure with entity=[svc]")
+		_, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: shapeBreakMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Getting measure to capture baseline ModRevision")
+		baseResp, getBaseErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getBaseErr).ShouldNot(gm.HaveOccurred())
+		baseModRev := baseResp.GetMeasure().GetMetadata().GetModRevision()
+		gm.Expect(baseModRev).Should(gm.BeNumerically(">", int64(0)))
+
+		g.By("Attempting UpdateMeasure with entity=[svc, host] (entity change)")
+		changedMeasure := baseResp.GetMeasure()
+		changedMeasure.Entity = &databasev1.Entity{TagNames: []string{"svc", "host"}}
+		_, updateErr := clients.MeasureRegClient.Update(ctx, &databasev1.MeasureRegistryServiceUpdateRequest{
+			Measure: changedMeasure,
+		})
+		gm.Expect(updateErr).Should(gm.HaveOccurred(), "UpdateMeasure with entity change must return an error")
+		gm.Expect(updateErr.Error()).Should(gm.ContainSubstring("entity"),
+			"the error message must reference 'entity'")
+
+		g.By("Verifying ModRevision is unchanged after rejected update")
+		afterResp, getAfterErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getAfterErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(afterResp.GetMeasure().GetMetadata().GetModRevision()).Should(
+			gm.Equal(baseModRev),
+			"ModRevision must be unchanged after a rejected entity-change update",
+		)
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.10: full measure content (entity, tag families, fields, created_at, updated_at, mod_revision)
+	// is unchanged after a rejected entity-change update.
+	g.It("leaves the full measure schema unchanged after a rejected entity-change update (§6.10)", func() {
+		groupName := fmt.Sprintf("sb2-measure-%d", time.Now().UnixNano())
+		measureName := "sb2_measure"
+
+		g.By("Creating measure group")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: shapeBreakMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating measure with entity=[svc]")
+		_, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: shapeBreakMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Getting measure to capture baseline state")
+		beforeResp, getBeforeErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getBeforeErr).ShouldNot(gm.HaveOccurred())
+		before := beforeResp.GetMeasure()
+
+		g.By("Attempting UpdateMeasure with entity=[svc, host] (entity change)")
+		changedMeasure := proto.Clone(before).(*databasev1.Measure)
+		changedMeasure.Entity = &databasev1.Entity{TagNames: []string{"svc", "host"}}
+		_, updateErr := clients.MeasureRegClient.Update(ctx, &databasev1.MeasureRegistryServiceUpdateRequest{
+			Measure: changedMeasure,
+		})
+		gm.Expect(updateErr).Should(gm.HaveOccurred(), "UpdateMeasure with entity change must return an error")
+
+		g.By("Verifying the full measure schema is unchanged after rejected update")
+		afterResp, getAfterErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getAfterErr).ShouldNot(gm.HaveOccurred())
+		after := afterResp.GetMeasure()
+
+		gm.Expect(after.GetMetadata().GetModRevision()).Should(
+			gm.Equal(before.GetMetadata().GetModRevision()),
+			"ModRevision must be unchanged",
+		)
+		gm.Expect(after.GetEntity().GetTagNames()).Should(
+			gm.Equal(before.GetEntity().GetTagNames()),
+			"Entity must be unchanged",
+		)
+		gm.Expect(after.GetTagFamilies()).Should(
+			gm.HaveLen(len(before.GetTagFamilies())),
+			"TagFamilies count must be unchanged",
+		)
+		gm.Expect(after.GetFields()).Should(
+			gm.HaveLen(len(before.GetFields())),
+			"Fields count must be unchanged",
+		)
+		gm.Expect(after.GetCreatedAt()).ShouldNot(gm.BeNil())
+		if before.GetCreatedAt() != nil {
+			gm.Expect(after.GetCreatedAt().AsTime().UnixNano()).Should(
+				gm.Equal(before.GetCreatedAt().AsTime().UnixNano()),
+				"created_at must be unchanged after rejected update",
+			)
+		}
+		gm.Expect(after.GetUpdatedAt()).ShouldNot(gm.BeNil())
+		gm.Expect(after.GetUpdatedAt().AsTime().UnixNano()).Should(
+			gm.Equal(before.GetUpdatedAt().AsTime().UnixNano()),
+			"updated_at must be unchanged after rejected update",
+		)
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §6.11: delete-then-recreate original shape drops old data (Rule 7 clamp).
+	g.It("delete-then-recreate original shape drops old data (§6.11)", func() {
+		// TODO(phase-2): Phase 1 AwaitRevisionApplied is liaison-only by design — it confirms
+		// the liaison cache observes R2 but not that every data node has rebuilt the tsTable
+		// after the delete-then-recreate. The post-recreate Write+Query round-trip exercised
+		// by this spec races the data-node tsTable rebuild on slow CI runners. Cluster-wide
+		// barrier semantics ship in Phase 2 via NodeSchemaStatusService + liaison fan-out
+		// (plan Steps 2.1–2.2); re-enable this spec in distributed mode once those land.
+		if SharedContext.Mode == "distributed" {
+			g.Skip("§6.11 requires cluster-wide propagation barrier (Phase 2)")
+		}
+		groupName := fmt.Sprintf("sb-same-%d", time.Now().UnixNano())
+		measureName := "throughput"
+
+		g.By("Creating measure group")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: shapeBreakMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Creating measure throughput entity=[host] → R1")
+		createResp1, createErr1 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: throughputMeasureSpecV1(groupName, measureName),
+		})
+		gm.Expect(createErr1).ShouldNot(gm.HaveOccurred())
+		r1 := createResp1.GetModRevision()
+
+		awaitErr1 := clients.AwaitRevision(ctx, r1, 10*time.Second)
+		gm.Expect(awaitErr1).ShouldNot(gm.HaveOccurred())
+
+		getResp1, getErr1 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr1).ShouldNot(gm.HaveOccurred())
+		createdAt1 := getResp1.GetMeasure().GetCreatedAt()
+		gm.Expect(createdAt1).ShouldNot(gm.BeNil())
+
+		g.By("Writing data at T_data1 = CreatedAt1 + 50ms; verifying query returns that point")
+		// T_data1 uses wall-clock now rather than a future offset from CreatedAt1 so
+		// a subsequent recreate stamps CreatedAt2 strictly after this timestamp.
+		tData1 := timestamp.NowMilli()
+		hostTags := []*modelv1.TagFamilyForWrite{{
+			Tags: []*modelv1.TagValue{{
+				Value: &modelv1.TagValue_Str{Str: &modelv1.Str{Value: "h1"}},
+			}},
+		}}
+		writeStatus1, writeErr1 := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName, tData1, r1, hostTags)
+		gm.Expect(writeErr1).ShouldNot(gm.HaveOccurred())
+		gm.Expect(writeStatus1).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()))
+
+		queryResp1, queryErr1 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+			createdAt1.AsTime(), time.Now().Add(time.Hour), r1)
+		gm.Expect(queryErr1).ShouldNot(gm.HaveOccurred())
+		gm.Expect(queryResp1.GetDataPoints()).Should(gm.HaveLen(1), "baseline: pre-delete query returns 1 data point")
+
+		g.By("Deleting measure → T_del; awaiting deletion")
+		deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+		tDel := deleteResp.GetDeleteTime()
+
+		awaitDelErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+		gm.Expect(awaitDelErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Recreating same spec entity=[host] → R2; CreatedAt2 > T_del")
+		createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: throughputMeasureSpecV1(groupName, measureName),
+		})
+		gm.Expect(createErr2).ShouldNot(gm.HaveOccurred())
+		r2 := createResp2.GetModRevision()
+
+		awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+		gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+		getResp2, getErr2 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr2).ShouldNot(gm.HaveOccurred())
+		got2 := getResp2.GetMeasure()
+		createdAt2 := got2.GetCreatedAt()
+		gm.Expect(createdAt2).ShouldNot(gm.BeNil())
+		gm.Expect(createdAt2.AsTime().UnixNano()).Should(gm.BeNumerically(">", tDel),
+			"CreatedAt2 must be after T_del")
+
+		g.By("Assertions: shape matches original, pre-delete data clamped, R2 write succeeds")
+		gm.Expect(got2.GetEntity().GetTagNames()).Should(gm.Equal([]string{"host"}),
+			"recreated measure must preserve original entity")
+
+		// Rule 7 clamp: query [T_data1, now+1h] returns empty because T_data1 < CreatedAt2.
+		queryResp2, queryErr2 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+			tData1, time.Now().Add(time.Hour), r2)
+		gm.Expect(queryErr2).ShouldNot(gm.HaveOccurred())
+		gm.Expect(queryResp2.GetDataPoints()).Should(gm.BeEmpty(),
+			"Rule 7 clamp must hide pre-delete data because T_data1 < CreatedAt2")
+
+		// Write with R2 must succeed.
+		tNewWrite := time.Now()
+		writeStatus2, writeErr2 := sendMeasureWriteAtTime(ctx, clients.MeasureWriteClient, groupName, measureName,
+			tNewWrite, r2, hostTags)
+		gm.Expect(writeErr2).ShouldNot(gm.HaveOccurred())
+		gm.Expect(writeStatus2).Should(gm.Equal(modelv1.Status_STATUS_SUCCEED.String()),
+			"write with R2 must succeed")
+
+		// Post-write query [CreatedAt2, now+1h] must return the newly-written point.
+		queryResp3, queryErr3 := queryMeasureRange(ctx, clients.MeasureWriteClient, groupName, measureName,
+			createdAt2.AsTime(), time.Now().Add(time.Hour), r2)
+		gm.Expect(queryErr3).ShouldNot(gm.HaveOccurred())
+		gm.Expect(queryResp3.GetDataPoints()).Should(gm.HaveLen(1),
+			"post-creation write must be queryable after AwaitRevision(R2)")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+})
diff --git a/test/cases/schema/tombstone_gc.go b/test/cases/schema/tombstone_gc.go
new file mode 100644
index 000000000..84e6e265b
--- /dev/null
+++ b/test/cases/schema/tombstone_gc.go
@@ -0,0 +1,187 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package schema contains shared test cases for schema-related functionality.
+//
+// §4.7.1 and §4.7.2 exercise tombstone retention behavior. The test suites wire
+// the standalone server with --schema-server-tombstone-retention=2s in the
+// BeforeSuite, so the specs run without requiring any environment variable.
+package schema
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	g "github.com/onsi/ginkgo/v2"
+	gm "github.com/onsi/gomega"
+
+	commonv1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+	databasev1 "github.com/apache/skywalking-banyandb/api/proto/banyandb/database/v1"
+)
+
+// tombstoneRetentionForTest mirrors the --schema-server-tombstone-retention flag
+// configured in the integration suite setup. Keep in sync with common.go.
+const tombstoneRetentionForTest = 2 * time.Second
+
+// Schema tombstone GC specs — §4.7.1 / §4.7.2.
+// The integration suite wires the standalone server with
+// --schema-server-tombstone-retention=tombstoneRetentionForTest in BeforeSuite,
+// so these specs run without any environment-variable setup.
+var _ = g.Describe("Schema tombstone GC", func() {
+	var (
+		ctx     context.Context
+		clients *Clients
+	)
+
+	g.BeforeEach(func() {
+		ctx = context.Background()
+		clients = NewClients(SharedContext.Connection)
+	})
+
+	// §4.7.1 — retains tombstone within retention window.
+	// The tombstone invariant (Step 1.3): server stamps updated_at = now() which is always
+	// after delete_time, so a normal recreate within the retention window always succeeds.
+	// This spec verifies that the recreate succeeds and CreatedAt2 is after T_del.
+	g.It("retains tombstone within retention window — recreate succeeds with fresh CreatedAt (§4.7.1)", func() {
+		retention := tombstoneRetentionForTest
+
+		groupName := fmt.Sprintf("tgc-retain-%d", time.Now().UnixNano())
+		measureName := "tgc_measure"
+
+		g.By("Creating measure group and measure")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: arMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+		r := createMeasureResp.GetModRevision()
+
+		awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Capturing T_create from GetMeasure.CreatedAt")
+		getResp, getErr := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(getResp.GetMeasure().GetCreatedAt()).ShouldNot(gm.BeNil())
+
+		g.By("Deleting measure → T_del; awaiting deletion")
+		deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(deleteResp.GetDeleted()).Should(gm.BeTrue())
+		tDel := deleteResp.GetDeleteTime()
+		gm.Expect(tDel).Should(gm.BeNumerically(">", int64(0)))
+
+		awaitDelErr := clients.AwaitDeleted(ctx, []string{fmt.Sprintf("measure:%s/%s", groupName, measureName)}, 10*time.Second)
+		gm.Expect(awaitDelErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Waiting half the retention duration (within the window)")
+		time.Sleep(retention / 2)
+
+		// The server stamps updated_at = now() on create, which is always > T_del.
+		// Therefore the tombstone invariant is satisfied and the recreate succeeds.
+		g.By("Recreating same measure spec within retention window — must succeed")
+		createResp2, createErr2 := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createErr2).ShouldNot(gm.HaveOccurred(),
+			"recreate within retention window must succeed: server stamps updated_at = now() > T_del")
+
+		r2 := createResp2.GetModRevision()
+		awaitErr2 := clients.AwaitRevision(ctx, r2, 10*time.Second)
+		gm.Expect(awaitErr2).ShouldNot(gm.HaveOccurred())
+
+		g.By("Verifying CreatedAt2 is after T_del (Step 1.3 tombstone invariant)")
+		getResp2, getErr2 := clients.MeasureRegClient.Get(ctx, &databasev1.MeasureRegistryServiceGetRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(getErr2).ShouldNot(gm.HaveOccurred())
+		createdAt2 := getResp2.GetMeasure().GetCreatedAt()
+		gm.Expect(createdAt2).ShouldNot(gm.BeNil())
+		gm.Expect(createdAt2.AsTime().UnixNano()).Should(gm.BeNumerically(">", tDel),
+			"CreatedAt2 must be after T_del — Step 1.3 tombstone invariant")
+
+		_, _ = clients.GroupClient.Delete(ctx, &databasev1.GroupRegistryServiceDeleteRequest{Group: groupName})
+	})
+
+	// §4.7.2 — physically removes tombstone after retention.
+	// After retention + 1s, the GC loop purges the tombstone. A recreate succeeds
+	// without any constraint from the (now-expired) tombstone.
+	g.It("physically removes tombstone after retention — recreate succeeds cleanly (§4.7.2)", func() {
+		retention := tombstoneRetentionForTest
+
+		groupName := fmt.Sprintf("tgc-gc-%d", time.Now().UnixNano())
+		measureName := "tgc_measure"
+
+		g.By("Creating measure group and measure")
+		_, createGroupErr := clients.GroupClient.Create(ctx, &databasev1.GroupRegistryServiceCreateRequest{
+			Group: arMeasureGroup(groupName),
+		})
+		gm.Expect(createGroupErr).ShouldNot(gm.HaveOccurred())
+
+		createMeasureResp, createMeasureErr := clients.MeasureRegClient.Create(ctx, &databasev1.MeasureRegistryServiceCreateRequest{
+			Measure: arMeasureSpec(groupName, measureName),
+		})
+		gm.Expect(createMeasureErr).ShouldNot(gm.HaveOccurred())
+		r := createMeasureResp.GetModRevision()
+
+		awaitErr := clients.AwaitRevision(ctx, r, 10*time.Second)
+		gm.Expect(awaitErr).ShouldNot(gm.HaveOccurred())
+
+		g.By("Deleting measure → T_del; awaiting deletion")
+		deleteResp, deleteErr := clients.MeasureRegClient.Delete(ctx, &databasev1.MeasureRegistryServiceDeleteRequest{
+			Metadata: &commonv1.Metadata{Name: measureName, Group: groupName},
+		})
+		gm.Expect(deleteErr).ShouldNot(gm.HaveOccurred())
+		gm.Expect(deleteResp.GetDeleted()).Should(gm.BeTrue())
+
... 451 lines suppressed ...