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

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new 1e4339461 fix(xlang): close field tag validation gaps (#3984)
1e4339461 is described below

commit 1e433946138c32c9203b87232c0f3409e10815a6
Author: Shawn Yang <[email protected]>
AuthorDate: Wed Aug 26 12:32:55 2026 +0800

    fix(xlang): close field tag validation gaps (#3984)
    
    ## Why?
    
    PR #3982 established the signed-int32 field-tag protocol range.
    Follow-up integration checks found a
    small set of validation ownership, exact-schema coverage, and
    documentation gaps that were not part
    of the squash-merged boundary.
    
    This PR completes those paths without changing the field-tag wire format
    introduced by #3982.
    
    ## What does this PR do?
    
    - Rejects duplicate Java native TypeDef tags across the complete
    inheritance hierarchy.
    - Validates Go field tags before public registration mutates resolver
    state while continuing to
      ignore unexported fields.
    - Keeps Rust protocol metadata equality independent from local
    compatible-reader dispatch state.
    - Adds causal C++ remote-compatible and oversized-tag decoder coverage.
    - Adds an exact-schema cross-language fixture for tags `15`, `65551`,
    and `536870911` across all
      supported peers, including normal concrete Java test discovery.
    - Runs Kotlin's maximum tag through a real KSP-generated serializer,
    descriptor, and round trip.
    - Documents the exact field-tag range, uniqueness, stability, and
    extended encoding rules in the
      protocol, compiler, and runtime schema guides.
    
    ## Related issues
    
    Follow-up to #3982.
    
    ## Does this PR introduce any user-facing change?
    
    - [ ] Does this PR introduce any public API change?
    - [ ] Does this PR introduce any binary protocol compatibility change?
    
    Invalid duplicate or out-of-range field-tag schemas are now rejected
    consistently at their owning
    registration or metadata boundary. Existing valid schemas and wire bytes
    are unchanged.
    
    ## Test
    
    - Java native TypeDef, Go registration, C++ metadata, Rust metadata
    equality, and Kotlin KSP/static
      serializer tests passed.
    - Exact-schema and compatible Java-driven field-tag cases passed against
    C++, C#, Dart, Go,
      JavaScript, Python, Rust, Scala, and Swift in both Java codegen modes.
    - Dart IDL regeneration, compilation, and Java/Dart semantic round trips
    passed.
    - Java Spotless, Rust fmt/clippy, Go formatting, C++ formatting,
    Markdown Prettier, and full diff
      checks passed.
    
    ## Benchmark
    
    The production changes in this follow-up are Java remote native-TypeDef
    miss validation, Go
    registration-time validation, and Rust `FieldInfo` equality. None is
    reached by the steady-state
    serialize/deserialize benchmark loops from #3982, so those paired
    results remain the causal hot-path
    evidence. Every measured runtime/path stayed below the `+1.0%`
    regression ceiling; the largest
    positive paired medians were C++ deserialize at `+0.858%` and Java
    static serialize at `+0.779%`.
---
 .github/workflows/ci.yml                           |  2 +-
 cpp/fory/serialization/struct_test.cc              | 35 ++++++++++++
 cpp/fory/serialization/xlang_test_main.cc          | 29 ++++++++++
 csharp/tests/Fory.XlangPeer/Program.cs             | 19 +++++++
 .../fory-test/lib/entity/xlang_test_models.dart    | 14 +++++
 .../test/cross_lang_test/xlang_test_main.dart      |  5 ++
 docs/compiler/schema-idl.md                        | 17 +++---
 docs/object-serialization/cpp/schema-metadata.md   |  5 +-
 .../object-serialization/csharp/schema-metadata.md |  3 +-
 docs/object-serialization/dart/schema-metadata.md  |  1 +
 docs/object-serialization/go/schema-metadata.md    |  4 ++
 docs/object-serialization/go/troubleshooting.md    | 10 +++-
 docs/object-serialization/java/schema-metadata.md  | 15 +++---
 .../javascript/schema-metadata.md                  | 18 +++++++
 .../object-serialization/kotlin/schema-metadata.md |  4 ++
 .../object-serialization/python/schema-metadata.md |  7 +--
 docs/object-serialization/rust/schema-metadata.md  |  3 +-
 docs/object-serialization/scala/schema-metadata.md |  4 ++
 docs/object-serialization/swift/schema-metadata.md | 18 +++++++
 docs/specification/java_serialization_spec.md      | 12 ++---
 docs/specification/xlang_serialization_spec.md     |  6 ++-
 go/fory/doc.go                                     |  2 +-
 go/fory/tag.go                                     |  3 ++
 go/fory/tag_test.go                                | 27 ++++++++--
 go/fory/tests/xlang/xlang_test_main.go             | 28 ++++++++++
 go/fory/type_resolver.go                           |  6 +++
 .../org/apache/fory/meta/NativeTypeDefDecoder.java | 23 +++++---
 .../apache/fory/meta/NativeTypeDefEncoderTest.java | 62 ++++++++++++++++++++++
 .../java/org/apache/fory/xlang/CPPXlangTest.java   |  6 +++
 .../org/apache/fory/xlang/CSharpXlangTest.java     |  6 +++
 .../java/org/apache/fory/xlang/DartXlangTest.java  |  6 +++
 .../java/org/apache/fory/xlang/GoXlangTest.java    |  6 +++
 .../org/apache/fory/xlang/JavaScriptXlangTest.java |  6 +++
 .../org/apache/fory/xlang/PythonXlangTest.java     |  6 +++
 .../java/org/apache/fory/xlang/RustXlangTest.java  |  6 +++
 .../java/org/apache/fory/xlang/ScalaXlangTest.java |  6 +++
 .../java/org/apache/fory/xlang/SwiftXlangTest.java |  6 +++
 .../java/org/apache/fory/xlang/XlangTestBase.java  | 34 ++++++++++++
 javascript/test/crossLanguage.test.ts              | 21 ++++++++
 .../fory/kotlin/ksp/ProcessorValidationTest.kt     |  4 +-
 .../apache/fory/kotlin/xlang/KotlinXlangPeer.kt    | 12 +++++
 python/pyfory/field.py                             |  7 +--
 python/pyfory/tests/xlang_test_main.py             | 21 ++++++++
 rust/fory-core/src/meta/type_meta.rs               | 15 +++++-
 rust/tests/tests/test_cross_language.rs            | 28 ++++++++++
 .../fory/serializer/scala/ScalaXlangPeer.scala     | 14 +++++
 swift/Tests/ForyXlangTests/main.swift              | 18 +++++++
 47 files changed, 560 insertions(+), 50 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7b7e070d7..16108ec7d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -559,7 +559,7 @@ jobs:
             ~/.swiftpm
             ~/Library/Caches/org.swift.swiftpm
             swift/.build
-          key: ${{ runner.os }}-${{ runner.arch }}-swiftpm-xlang-release-${{ 
steps.swift-package-cache.outputs.toolchain }}-${{ 
hashFiles('swift/Package.resolved', 'swift/Package.swift', 'swift/Sources/**') 
}}
+          key: ${{ runner.os }}-${{ runner.arch }}-swiftpm-xlang-release-${{ 
steps.swift-package-cache.outputs.toolchain }}-${{ 
hashFiles('swift/Package.resolved', 'swift/Package.swift', 'swift/Sources/**', 
'swift/Tests/ForyXlangTests/**') }}
           restore-keys: |
             ${{ runner.os }}-${{ runner.arch }}-swiftpm-xlang-release-${{ 
steps.swift-package-cache.outputs.toolchain }}-
       - name: Prebuild Swift xlang peer for cache reuse
diff --git a/cpp/fory/serialization/struct_test.cc 
b/cpp/fory/serialization/struct_test.cc
index 67d856185..30bd8c735 100644
--- a/cpp/fory/serialization/struct_test.cc
+++ b/cpp/fory/serialization/struct_test.cc
@@ -146,6 +146,20 @@ struct MaximumFieldTagStruct {
   FORY_STRUCT(MaximumFieldTagStruct, (value, fory::F(536870911)));
 };
 
+struct HighTagCompatibleWriter {
+  std::string removed;
+  int32_t shared;
+
+  FORY_STRUCT(HighTagCompatibleWriter, (removed, fory::F(65551)),
+              (shared, fory::F(536870911)));
+};
+
+struct HighTagCompatibleReader {
+  int32_t shared = 0;
+
+  FORY_STRUCT(HighTagCompatibleReader, (shared, fory::F(536870911)));
+};
+
 struct SignedToUnsignedWriter {
   int32_t value;
 
@@ -1403,6 +1417,12 @@ TEST(StructComprehensiveTest, FieldTagRange) {
   invalid.field_id = 536870912;
   EXPECT_FALSE(invalid.to_bytes().ok());
 
+  Buffer invalid_wire;
+  invalid_wire.write_uint8(static_cast<uint8_t>((3u << 6) | (15u << 2)));
+  invalid_wire.write_var_uint32(536870912u - 15u);
+  invalid_wire.write_uint8(static_cast<uint8_t>(TypeId::INT32));
+  EXPECT_FALSE(FieldInfo::from_bytes(invalid_wire).ok());
+
   TypeMeta duplicate;
   duplicate.type_id = static_cast<uint32_t>(TypeId::COMPATIBLE_STRUCT);
   duplicate.user_type_id = 632;
@@ -1412,6 +1432,21 @@ TEST(StructComprehensiveTest, FieldTagRange) {
   EXPECT_FALSE(duplicate.to_bytes().ok());
 }
 
+TEST(StructComprehensiveTest, CompatibleHighTagsRead) {
+  auto writer =
+      Fory::builder().xlang(true).compatible(true).track_ref(false).build();
+  auto reader =
+      Fory::builder().xlang(true).compatible(true).track_ref(false).build();
+  ASSERT_TRUE(writer.register_struct<HighTagCompatibleWriter>(633).ok());
+  ASSERT_TRUE(reader.register_struct<HighTagCompatibleReader>(633).ok());
+
+  auto encoded = writer.serialize(HighTagCompatibleWriter{"skip", 42});
+  ASSERT_TRUE(encoded.ok()) << encoded.error().to_string();
+  auto decoded = reader.deserialize<HighTagCompatibleReader>(*encoded);
+  ASSERT_TRUE(decoded.ok()) << decoded.error().to_string();
+  EXPECT_EQ(decoded->shared, 42);
+}
+
 TEST(StructComprehensiveTest, NonPrimitiveFieldsSortByFieldIdentifier) {
   auto fields = TypeMeta::sort_field_infos({
       make_test_field_info("string_value", 20,
diff --git a/cpp/fory/serialization/xlang_test_main.cc 
b/cpp/fory/serialization/xlang_test_main.cc
index 0009504e7..7ccc5049c 100644
--- a/cpp/fory/serialization/xlang_test_main.cc
+++ b/cpp/fory/serialization/xlang_test_main.cc
@@ -137,6 +137,17 @@ struct SimpleStruct {
               (f7, fory::F(65551)), f8, (last, fory::F(536870911)));
 };
 
+struct ExactFieldTags {
+  int32_t first;
+  int32_t second;
+  int32_t last;
+  bool operator==(const ExactFieldTags &other) const {
+    return first == other.first && second == other.second && last == 
other.last;
+  }
+  FORY_STRUCT(ExactFieldTags, (first, fory::F(15)), (second, fory::F(65551)),
+              (last, fory::F(536870911)));
+};
+
 struct EvolvingOverrideStruct {
   std::string f1;
   bool operator==(const EvolvingOverrideStruct &other) const {
@@ -938,6 +949,7 @@ namespace {
 void run_test_buffer(const std::string &data_file);
 void run_test_buffer_var(const std::string &data_file);
 void run_test_murmur_hash3(const std::string &data_file);
+void run_test_exact_field_tags(const std::string &data_file);
 void run_test_string_serializer(const std::string &data_file);
 void run_test_cross_language_serializer(const std::string &data_file);
 void run_test_simple_struct(const std::string &data_file);
@@ -1030,6 +1042,8 @@ int main(int argc, char **argv) {
       run_test_cross_language_serializer(data_file);
     } else if (case_name == "test_simple_struct") {
       run_test_simple_struct(data_file);
+    } else if (case_name == "test_exact_field_tags") {
+      run_test_exact_field_tags(data_file);
     } else if (case_name == "test_named_simple_struct") {
       run_test_simple_named_struct(data_file);
     } else if (case_name == "test_struct_evolving_override") {
@@ -1570,6 +1584,21 @@ void run_test_simple_struct(const std::string 
&data_file) {
   write_file(data_file, out);
 }
 
+void run_test_exact_field_tags(const std::string &data_file) {
+  auto fory = build_fory(false, true);
+  ensure_ok(fory.register_struct<ExactFieldTags>(104),
+            "register ExactFieldTags");
+  auto bytes = read_file(data_file);
+  Buffer buffer = make_buffer(bytes);
+  auto value = read_next<ExactFieldTags>(fory, buffer);
+  if (!(value == ExactFieldTags{39, 40, 41})) {
+    fail("ExactFieldTags mismatch");
+  }
+  std::vector<uint8_t> out;
+  append_serialized(fory, value, out);
+  write_file(data_file, out);
+}
+
 void run_test_simple_named_struct(const std::string &data_file) {
   auto bytes = read_file(data_file);
   auto fory = build_fory(true, true);
diff --git a/csharp/tests/Fory.XlangPeer/Program.cs 
b/csharp/tests/Fory.XlangPeer/Program.cs
index a3abca8df..c78a114d6 100644
--- a/csharp/tests/Fory.XlangPeer/Program.cs
+++ b/csharp/tests/Fory.XlangPeer/Program.cs
@@ -194,6 +194,7 @@ internal static class Program
             "test_string_serializer" => CaseStringSerializer(input),
             "test_cross_language_serializer" => 
CaseCrossLanguageSerializer(input),
             "test_simple_struct" => CaseSimpleStruct(input),
+            "test_exact_field_tags" => CaseExactFieldTags(input),
             "test_named_simple_struct" => CaseNamedSimpleStruct(input),
             "test_struct_evolving_override" => 
CaseStructEvolvingOverride(input),
             "test_list" => CaseList(input),
@@ -461,6 +462,13 @@ internal static class Program
         return RoundTripSingle<SimpleStruct>(input, fory);
     }
 
+    private static byte[] CaseExactFieldTags(byte[] input)
+    {
+        ForyRuntime fory = BuildFory(compatible: false);
+        fory.Register<ExactFieldTags>(104);
+        return RoundTripSingle<ExactFieldTags>(input, fory);
+    }
+
     private static byte[] CaseNamedSimpleStruct(byte[] input)
     {
         ForyRuntime fory = BuildFory(compatible: true);
@@ -1282,6 +1290,17 @@ public sealed class SimpleStruct
     public int Last { get; set; }
 }
 
+[ForyStruct]
+public sealed class ExactFieldTags
+{
+    [ForyField(15)]
+    public int First { get; set; }
+    [ForyField(65551)]
+    public int Second { get; set; }
+    [ForyField(536870911)]
+    public int Last { get; set; }
+}
+
 [ForyStruct]
 public sealed class EvolvingOverrideStruct
 {
diff --git a/dart/packages/fory-test/lib/entity/xlang_test_models.dart 
b/dart/packages/fory-test/lib/entity/xlang_test_models.dart
index 5c7ac5229..467b73b49 100644
--- a/dart/packages/fory-test/lib/entity/xlang_test_models.dart
+++ b/dart/packages/fory-test/lib/entity/xlang_test_models.dart
@@ -79,6 +79,20 @@ class SimpleStruct {
   int last = 0;
 }
 
+@ForyStruct()
+class ExactFieldTags {
+  ExactFieldTags();
+
+  @ForyField(id: 15, type: Int32Type())
+  int first = 0;
+
+  @ForyField(id: 65551, type: Int32Type())
+  int second = 0;
+
+  @ForyField(id: 536870911, type: Int32Type())
+  int last = 0;
+}
+
 @ForyStruct()
 class EvolvingOverrideStruct {
   EvolvingOverrideStruct();
diff --git a/dart/packages/fory-test/test/cross_lang_test/xlang_test_main.dart 
b/dart/packages/fory-test/test/cross_lang_test/xlang_test_main.dart
index 2f6d4956e..033fb400a 100644
--- a/dart/packages/fory-test/test/cross_lang_test/xlang_test_main.dart
+++ b/dart/packages/fory-test/test/cross_lang_test/xlang_test_main.dart
@@ -491,6 +491,11 @@ void _runCase(String caseName) {
       _registerSimpleById(fory);
       _roundTripFory(fory);
       return;
+    case 'test_exact_field_tags':
+      final fory = _newFory();
+      registerXlangType(fory, ExactFieldTags, id: 104);
+      _roundTripFory(fory);
+      return;
     case 'test_named_simple_struct':
       final fory = _newFory(compatible: true);
       _registerSimpleByName(fory);
diff --git a/docs/compiler/schema-idl.md b/docs/compiler/schema-idl.md
index 4291e9b64..9c1244fdd 100644
--- a/docs/compiler/schema-idl.md
+++ b/docs/compiler/schema-idl.md
@@ -976,6 +976,11 @@ Fields define the properties of a message.
 field_type field_name = field_number;
 ```
 
+`field_number` is the field tag ID. It must be unique within the message and
+satisfy `0 <= field_number < 2^29` (`0` through `536870911`). Keep
+assigned field numbers stable and reserve removed numbers instead of reusing
+them for different fields.
+
 ### With Modifiers
 
 ```protobuf
@@ -1149,22 +1154,22 @@ Use `ref(thread_safe=false)` in Fory IDL (or
 
 ## Field Numbers
 
-Each field must have a unique positive integer identifier:
+Each field must have a unique tag ID in the protocol range:
 
 ```protobuf
 message Example {
-    string first = 1;
-    string second = 2;
-    string third = 3;
+    string first = 0;
+    string second = 1;
+    string third = 2;
 }
 ```
 
 **Rules and best practices:**
 
 - Numbers must be unique within a message.
-- Numbers must be positive integers.
+- Numbers must satisfy `0 <= field_number < 2^29` (`0` through `536870911`).
 - Gaps are allowed and are useful when fields are removed.
-- Prefer sequential numbering from `1`.
+- Prefer sequential numbering.
 - Never reuse a removed field number for a different field.
 
 ## Type System
diff --git a/docs/object-serialization/cpp/schema-metadata.md 
b/docs/object-serialization/cpp/schema-metadata.md
index c60f3866e..5c05de1c0 100644
--- a/docs/object-serialization/cpp/schema-metadata.md
+++ b/docs/object-serialization/cpp/schema-metadata.md
@@ -59,8 +59,9 @@ FORY_STRUCT(DataV2, id, (timestamp, fory::F().tagged()), 
version);
 FORY_STRUCT(Counter, FORY_PROPERTY(value, fory::F().varint()));
 ```
 
-`fory::F(id)` uses explicit id-based field identity. IDs must be
-non-negative:
+`fory::F(id)` uses explicit id-based field identity. Configured IDs must be
+unique within the complete struct schema and satisfy `0 <= id < 2^29` (`0`
+through `536870911`):
 
 ```cpp
 FORY_STRUCT(DataV2, (id, fory::F(0)), (timestamp, fory::F(1).tagged()),
diff --git a/docs/object-serialization/csharp/schema-metadata.md 
b/docs/object-serialization/csharp/schema-metadata.md
index d9858982e..c55567144 100644
--- a/docs/object-serialization/csharp/schema-metadata.md
+++ b/docs/object-serialization/csharp/schema-metadata.md
@@ -23,7 +23,7 @@ This page covers schema metadata for C# generated serializers.
 
 ## `[ForyStruct]` and `[ForyField]`
 
-Use `[ForyStruct]` to enable source-generated serializers. Use `[ForyField]` 
to assign an optional stable non-negative field id or to override the Fory 
schema type used for a field.
+Use `[ForyStruct]` to enable source-generated serializers. Use `[ForyField]` 
to assign an optional stable field ID or to override the Fory schema type used 
for a field. Configured IDs must be unique within the complete struct schema 
and satisfy `0 <= id < 2^29` (`0` through `536870911`).
 
 External-type serialization puts `Target` on a local abstract serializer
 declaration. Its properties own the field names, IDs, schema descriptors,
@@ -77,6 +77,7 @@ public sealed class Metrics
 ```
 
 `Id` is optional. When it is omitted, compatible mode still matches the field 
by name.
+Once assigned, keep an ID stable and do not reuse it for a different field.
 
 ```csharp
 using Apache.Fory;
diff --git a/docs/object-serialization/dart/schema-metadata.md 
b/docs/object-serialization/dart/schema-metadata.md
index 3073cf6bb..ed4a957cc 100644
--- a/docs/object-serialization/dart/schema-metadata.md
+++ b/docs/object-serialization/dart/schema-metadata.md
@@ -69,6 +69,7 @@ String name = '';
 ```
 
 Once a payload is shared across services, never reuse an `id` for a different 
field.
+Configured IDs must satisfy `0 <= id < 2^29` (`0` through `536870911`).
 
 An ordinary child has one flattened field namespace. IDs must therefore be
 unique across all fields included from its child, superclass, and
diff --git a/docs/object-serialization/go/schema-metadata.md 
b/docs/object-serialization/go/schema-metadata.md
index 878661dab..7073e1e24 100644
--- a/docs/object-serialization/go/schema-metadata.md
+++ b/docs/object-serialization/go/schema-metadata.md
@@ -47,6 +47,10 @@ type User struct {
 }
 ```
 
+Configured IDs must be unique within the struct schema and satisfy
+`0 <= id < 2^29` (`0` through `536870911`). Keep assigned IDs stable and do
+not reuse them for different fields.
+
 **Benefits**:
 
 - Smaller serialized size (numeric IDs vs field names)
diff --git a/docs/object-serialization/go/troubleshooting.md 
b/docs/object-serialization/go/troubleshooting.md
index 9c51ba6be..be7fa1e9b 100644
--- a/docs/object-serialization/go/troubleshooting.md
+++ b/docs/object-serialization/go/troubleshooting.md
@@ -206,7 +206,8 @@ f2 := fory.New(fory.WithTrackRef(true))  // Must match!
 
 **Common causes**:
 
-1. **Invalid tag ID**: ID must be non-negative
+1. **Invalid tag ID**: ID must satisfy `0 <= id < 2^29` (`0` through
+   `536870911`)
 
 ```go
 // Wrong: negative ID
@@ -218,6 +219,11 @@ type Bad struct {
 type Good struct {
     Field int `fory:"id=0"`
 }
+
+// Wrong: ID reaches the exclusive upper bound
+type TooLarge struct {
+    Field int `fory:"id=536870912"`
+}
 ```
 
 2. **Duplicate tag IDs**: Each field must have a unique ID within the struct
@@ -256,7 +262,7 @@ type User struct {
 }
 ```
 
-2. **Use field IDs for consistent ordering**: Field IDs (non-negative 
integers) act as aliases for field names, used for both sorting and field 
matching during deserialization:
+2. **Use field IDs for consistent ordering**: Field IDs in the protocol range 
act as aliases for field names, used for both sorting and field matching during 
deserialization:
 
 ```go
 type User struct {
diff --git a/docs/object-serialization/java/schema-metadata.md 
b/docs/object-serialization/java/schema-metadata.md
index 981e47184..8a1265d84 100644
--- a/docs/object-serialization/java/schema-metadata.md
+++ b/docs/object-serialization/java/schema-metadata.md
@@ -87,10 +87,10 @@ public class User {
 
 ### Parameters
 
-| Parameter | Type      | Default | Description                            |
-| --------- | --------- | ------- | -------------------------------------- |
-| `id`      | `int`     | `-1`    | Non-negative field tag ID, or no ID    |
-| `dynamic` | `Dynamic` | `AUTO`  | Control polymorphism for struct fields |
+| Parameter | Type      | Default | Description                                
  |
+| --------- | --------- | ------- | 
-------------------------------------------- |
+| `id`      | `int`     | `-1`    | Field tag ID, or the internal no-ID 
sentinel |
+| `dynamic` | `Dynamic` | `AUTO`  | Control polymorphism for struct fields     
  |
 
 Use `@Nullable` on the field type or nested type position for nullable schema
 metadata and `@Ref` for reference tracking. `@ForyField` does not carry either
@@ -123,10 +123,11 @@ public class User {
 
 **Notes**:
 
-- IDs must be unique within a class
-- IDs must be >= 0 when configured
-- If not specified, the annotation default `-1` is ignored and field name is 
used in metadata
+- Configured IDs must satisfy `0 <= id < 2^29` (`0` through `536870911`)
+- IDs must be unique within the complete struct schema, including inherited 
fields
+- If not specified, the annotation default `-1` is the internal no-ID sentinel 
and the field name is used in metadata
   (larger overhead)
+- Once assigned, keep an ID stable and do not reuse it for a different field
 
 **Without field IDs** (field names used in metadata):
 
diff --git a/docs/object-serialization/javascript/schema-metadata.md 
b/docs/object-serialization/javascript/schema-metadata.md
index c891701b6..688479597 100644
--- a/docs/object-serialization/javascript/schema-metadata.md
+++ b/docs/object-serialization/javascript/schema-metadata.md
@@ -50,6 +50,24 @@ const byName = Type.struct(
 
 Use `.` inside `typeName` to add a namespace prefix.
 
+## Field IDs
+
+Call `setId(id)` on a field type to assign a stable numeric field identity:
+
+```ts
+const userType = Type.struct(
+  { typeId: 1001 },
+  {
+    id: Type.int64().setId(0),
+    name: Type.string().setId(1),
+  },
+);
+```
+
+Configured IDs must be unique within the struct schema and satisfy
+`0 <= id < 2^29` (`0` through `536870911`). Keep assigned IDs stable and do
+not reuse them for different fields. Fields without an ID use their names.
+
 ## Decorator Metadata
 
 Decorators keep the schema next to a TypeScript class declaration:
diff --git a/docs/object-serialization/kotlin/schema-metadata.md 
b/docs/object-serialization/kotlin/schema-metadata.md
index b46bd14f9..a7fb57abd 100644
--- a/docs/object-serialization/kotlin/schema-metadata.md
+++ b/docs/object-serialization/kotlin/schema-metadata.md
@@ -51,6 +51,10 @@ Use `@ForyField(id = 1)` on constructor properties. 
`@field:ForyField(id = 1)` i
 for field-backed properties. Do not use `@get:ForyField` or `@set:ForyField`; 
accessors are not
 schema fields and the processor rejects them.
 
+Configured field IDs must be unique within the struct schema and satisfy
+`0 <= id < 2^29` (`0` through `536870911`). Keep assigned IDs stable and do
+not reuse them for different fields. If `id` is omitted, the field name is 
used.
+
 ## Nullability
 
 Use Kotlin `?` to describe nullable schema positions. Nullability is preserved 
inside collections
diff --git a/docs/object-serialization/python/schema-metadata.md 
b/docs/object-serialization/python/schema-metadata.md
index e7359c96a..cd8517dba 100644
--- a/docs/object-serialization/python/schema-metadata.md
+++ b/docs/object-serialization/python/schema-metadata.md
@@ -72,7 +72,7 @@ class User:
 
 | Parameter         | Type     | Default   | Description                       
   |
 | ----------------- | -------- | --------- | 
------------------------------------ |
-| `id`              | `int`    | omitted   | Non-negative field tag ID         
   |
+| `id`              | `int`    | omitted   | Field tag ID in `0 <= id < 2^29`  
   |
 | `nullable`        | `bool`   | `False`   | Whether the field can be null     
   |
 | `ref`             | `bool`   | `False`   | Enable reference tracking         
   |
 | `ignore`          | `bool`   | `False`   | Exclude field from serialization  
   |
@@ -102,9 +102,10 @@ class User:
 
 **Notes**:
 
-- IDs must be unique within a class
-- IDs must be >= 0
+- IDs must be unique within the struct schema
+- IDs must satisfy `0 <= id < 2^29` (`0` through `536870911`)
 - If not specified, field name is used in metadata (larger overhead)
+- Once assigned, keep an ID stable and do not reuse it for a different field
 
 **Without field IDs** (field names used in metadata):
 
diff --git a/docs/object-serialization/rust/schema-metadata.md 
b/docs/object-serialization/rust/schema-metadata.md
index 249f9a906..4b18c04d7 100644
--- a/docs/object-serialization/rust/schema-metadata.md
+++ b/docs/object-serialization/rust/schema-metadata.md
@@ -83,8 +83,9 @@ struct User {
 **Notes**:
 
 - IDs must be unique within a struct
-- IDs must be non-negative
+- IDs must satisfy `0 <= id < 2^29` (`0` through `536870911`)
 - If not specified, field name is used in metadata (larger overhead)
+- Once assigned, keep an ID stable and do not reuse it for a different field
 
 ### Skipping Fields (`skip`)
 
diff --git a/docs/object-serialization/scala/schema-metadata.md 
b/docs/object-serialization/scala/schema-metadata.md
index d63069082..151c8cdb6 100644
--- a/docs/object-serialization/scala/schema-metadata.md
+++ b/docs/object-serialization/scala/schema-metadata.md
@@ -38,6 +38,10 @@ final case class Person(
 ) derives ForySerializer
 ```
 
+Configured field IDs must be unique within the struct schema and satisfy
+`0 <= id < 2^29` (`0` through `536870911`). Keep assigned IDs stable and do
+not reuse them for different fields. If `id` is omitted, the field name is 
used.
+
 Schema `optional T` fields are represented as `Option[T]`.
 
 ## Reference Tracking
diff --git a/docs/object-serialization/swift/schema-metadata.md 
b/docs/object-serialization/swift/schema-metadata.md
index e80521000..2a216e4f7 100644
--- a/docs/object-serialization/swift/schema-metadata.md
+++ b/docs/object-serialization/swift/schema-metadata.md
@@ -26,6 +26,7 @@ This page covers macro-level schema metadata in Swift.
 - `@ForyStruct` on struct/class models and external structural serializers
 - `@ForyEnum` on C-style enum models and external enum serializers
 - `@ForyUnion` and `@ForyCase` on associated-value enum models and external 
union serializers
+- `@ForyField(id: ...)` for stable numeric field identity
 - `@ForyField(encoding: ...)` on numeric fields
 - `@ForyField(with: ...)` for exact serializer selection
 - `@ListField`, `@ArrayField`, `@SetField`, and `@MapField` for collection 
field metadata
@@ -47,6 +48,23 @@ Equivalent target arguments are available on `@ForyEnum` and 
`@ForyUnion`.
 See [External-Type Serialization](external-types.md) for target access and
 construction requirements.
 
+## `@ForyField(id:)`
+
+Assign an ID when field identity must remain stable across renames or schema
+evolution:
+
+```swift
+@ForyStruct
+struct User {
+    @ForyField(id: 1)
+    var name: String
+}
+```
+
+Configured IDs must be unique within the struct schema and satisfy
+`0 <= id < 2^29` (`0` through `536870911`). Keep assigned IDs stable and do
+not reuse them for different fields. Fields without an ID use their names.
+
 ## `@ForyField(with:)`
 
 An unannotated field implicitly selects its declared type when that type
diff --git a/docs/specification/java_serialization_spec.md 
b/docs/specification/java_serialization_spec.md
index b2802f2a7..012d27f06 100644
--- a/docs/specification/java_serialization_spec.md
+++ b/docs/specification/java_serialization_spec.md
@@ -317,12 +317,12 @@ participate in field order.
 
 Field identifiers are selected as follows:
 
-- If a field has an explicit non-negative `@ForyField(id = ...)`, that numeric
-  ID is the field identifier.
-- Otherwise, the Java field name converted to snake_case is the field
-  identifier.
-- Negative annotation values are not valid field IDs. The annotation default
-  value `-1` means no explicit ID and is ignored for identifier selection.
+- If a field has an explicit `@ForyField(id = ...)` in the range
+  `0 <= id < 2^29`, that numeric ID is the field identifier.
+- The annotation default `id = -1`, including a field without an explicit
+  `@ForyField` ID, uses the Java field name converted to snake_case.
+- Every other annotation ID is invalid; values below `-1` and values greater
+  than or equal to `2^29` do not select name-based identity.
 
 Identifier comparison is:
 
diff --git a/docs/specification/xlang_serialization_spec.md 
b/docs/specification/xlang_serialization_spec.md
index fa77b9e1e..83f579a65 100644
--- a/docs/specification/xlang_serialization_spec.md
+++ b/docs/specification/xlang_serialization_spec.md
@@ -840,9 +840,13 @@ Field info list:
 Each field is encoded as:
 
 ```
-| field header (1 byte) | field type info | [field name bytes] |
+| field header (1 byte) | [extended name or tag value] | field type info | 
[field name bytes] |
 ```
 
+The optional extended value is a `varuint32`. It is present when the four-bit
+size field is saturated for a long encoded name or an extended tag ID, as
+defined below.
+
 Field header layout:
 
 - Bits 6-7: field name encoding (`UTF8`, `ALL_TO_LOWER_SPECIAL`,
diff --git a/go/fory/doc.go b/go/fory/doc.go
index af53747af..04e3646ad 100644
--- a/go/fory/doc.go
+++ b/go/fory/doc.go
@@ -152,7 +152,7 @@ Fory supports struct tags for field-level configuration:
        }
 
 Available tags:
-  - id=N: Assign numeric field ID for compact encoding
+  - id=N: Assign a field ID in 0 <= N < 2^29 for compact encoding
   - "-": Exclude field from serialization
   - ref: Enable reference tracking for pointer/slice/map fields
   - nullable: Write null flag for pointer fields
diff --git a/go/fory/tag.go b/go/fory/tag.go
index f5d2dc600..97d862331 100644
--- a/go/fory/tag.go
+++ b/go/fory/tag.go
@@ -53,6 +53,9 @@ func validateForyTags(t reflect.Type) error {
        tagIDs := make(map[int32]string)
        for i := 0; i < t.NumField(); i++ {
                field := t.Field(i)
+               if field.PkgPath != "" {
+                       continue
+               }
                parsed, err := parseFieldTag(field)
                if err != nil {
                        return err
diff --git a/go/fory/tag_test.go b/go/fory/tag_test.go
index 906eebd36..ece5ecbfa 100644
--- a/go/fory/tag_test.go
+++ b/go/fory/tag_test.go
@@ -289,7 +289,7 @@ func TestParseFieldSpecRejectsInvalidTags(t *testing.T) {
 func TestValidateForyTagsStrict(t *testing.T) {
        type Valid struct {
                Field1 string  `fory:"id=0"`
-               Field2 []int32 
`fory:"id=1,type=list(element=int32(encoding=fixed))"`
+               Field2 []int32 
`fory:"id=536870911,type=list(element=int32(encoding=fixed))"`
        }
        type DuplicateIDs struct {
                Field1 string `fory:"id=0"`
@@ -302,13 +302,30 @@ func TestValidateForyTagsStrict(t *testing.T) {
        type InvalidID struct {
                Field1 string `fory:"id=-1"`
        }
+       type OversizedID struct {
+               Field1 string `fory:"id=536870912"`
+       }
+       type UnexportedInvalid struct {
+               Field1 string
+               hidden string `fory:"id=invalid"`
+       }
+
+       require.NoError(t, New().RegisterStruct(Valid{}, 100))
+       require.NoError(t, New().RegisterStructByName(IgnoredDuplicate{}, 
"test.IgnoredDuplicate"))
+       require.NoError(t, New().RegisterStructByName(UnexportedInvalid{}, 
"test.UnexportedInvalid"))
+
+       byID := New()
+       require.Error(t, byID.RegisterStruct(DuplicateIDs{}, 101))
+       require.NoError(t, byID.RegisterStruct(Valid{}, 101))
+
+       byName := New()
+       require.Error(t, byName.RegisterStructByName(DuplicateIDs{}, 
"test.Shared"))
+       require.NoError(t, byName.RegisterStructByName(Valid{}, "test.Shared"))
 
-       require.NoError(t, validateForyTags(reflect.TypeOf(Valid{})))
-       require.NoError(t, validateForyTags(reflect.TypeOf(IgnoredDuplicate{})))
-       require.Error(t, validateForyTags(reflect.TypeOf(DuplicateIDs{})))
-       err := validateForyTags(reflect.TypeOf(InvalidID{}))
+       err := New().RegisterStructByName(InvalidID{}, "test.InvalidID")
        require.Error(t, err)
        require.Contains(t, err.Error(), "id must be non-negative")
+       require.Error(t, New().RegisterStruct(OversizedID{}, 102))
 }
 
 func TestShouldIncludeField(t *testing.T) {
diff --git a/go/fory/tests/xlang/xlang_test_main.go 
b/go/fory/tests/xlang/xlang_test_main.go
index 1a57fac0b..a21ea3dbd 100644
--- a/go/fory/tests/xlang/xlang_test_main.go
+++ b/go/fory/tests/xlang/xlang_test_main.go
@@ -300,6 +300,12 @@ type SimpleStruct struct {
        Last int32 `fory:"id=536870911"`
 }
 
+type ExactFieldTags struct {
+       First  int32 `fory:"id=15"`
+       Second int32 `fory:"id=65551"`
+       Last   int32 `fory:"id=536870911"`
+}
+
 type EvolvingOverrideStruct struct {
        F1 string
 }
@@ -764,6 +770,26 @@ func testSimpleStruct() {
        writeFile(dataFile, serialized)
 }
 
+func testExactFieldTags() {
+       dataFile := getDataFile()
+       f := fory.New(fory.WithXlang(true), fory.WithCompatible(false))
+       if err := f.RegisterStruct(ExactFieldTags{}, 104); err != nil {
+               panic(fmt.Sprintf("Failed to register ExactFieldTags: %v", err))
+       }
+       var value ExactFieldTags
+       if err := f.Deserialize(readFile(dataFile), &value); err != nil {
+               panic(fmt.Sprintf("Failed to deserialize ExactFieldTags: %v", 
err))
+       }
+       if value != (ExactFieldTags{First: 39, Second: 40, Last: 41}) {
+               panic(fmt.Sprintf("Unexpected ExactFieldTags: %+v", value))
+       }
+       serialized, err := f.Serialize(&value)
+       if err != nil {
+               panic(fmt.Sprintf("Failed to serialize ExactFieldTags: %v", 
err))
+       }
+       writeFile(dataFile, serialized)
+}
+
 func testNamedSimpleStruct() {
        dataFile := getDataFile()
        data := readFile(dataFile)
@@ -2902,6 +2928,8 @@ func main() {
                testCrossLanguageSerializer()
        case "test_simple_struct":
                testSimpleStruct()
+       case "test_exact_field_tags":
+               testExactFieldTags()
        case "test_named_simple_struct":
                testNamedSimpleStruct()
        case "test_struct_evolving_override":
diff --git a/go/fory/type_resolver.go b/go/fory/type_resolver.go
index c1a7c202e..ed82c94d3 100644
--- a/go/fory/type_resolver.go
+++ b/go/fory/type_resolver.go
@@ -506,6 +506,9 @@ func (r *TypeResolver) RegisterStruct(type_ reflect.Type, 
typeID TypeId, userTyp
 
        switch type_.Kind() {
        case reflect.Struct:
+               if err := validateForyTags(type_); err != nil {
+                       return err
+               }
                if err := validateOptionalFields(type_); err != nil {
                        return err
                }
@@ -671,6 +674,9 @@ func (r *TypeResolver) registerStructByName(type_ 
reflect.Type, namespace, typeN
        if typeName == "" {
                return fmt.Errorf("typeName must be non-empty")
        }
+       if err := validateForyTags(type_); err != nil {
+               return err
+       }
        tag := joinRegisteredName(namespace, typeName)
        serializer := newStructSerializer(type_, tag)
        r.typeToSerializers[type_] = serializer
diff --git 
a/java/fory-core/src/main/java/org/apache/fory/meta/NativeTypeDefDecoder.java 
b/java/fory-core/src/main/java/org/apache/fory/meta/NativeTypeDefDecoder.java
index b7e053fa6..b69c42af2 100644
--- 
a/java/fory-core/src/main/java/org/apache/fory/meta/NativeTypeDefDecoder.java
+++ 
b/java/fory-core/src/main/java/org/apache/fory/meta/NativeTypeDefDecoder.java
@@ -224,6 +224,7 @@ class NativeTypeDefDecoder {
       List<FieldInfo> fieldInfos = readFieldsInfo(typeDefBuf, resolver, 
className, numFields);
       classFields.addAll(fieldInfos);
     }
+    validateTagIds(classFields);
     Preconditions.checkNotNull(classSpec);
     boolean hasFieldMetadata = !classFields.isEmpty();
     // Native TypeDef can carry class-layer fields even when the root wire 
type is an enum,
@@ -327,7 +328,6 @@ class NativeTypeDefDecoder {
   private static List<FieldInfo> readFieldsInfo(
       MemoryBuffer buffer, ClassResolver resolver, String className, int 
numFields) {
     List<FieldInfo> fieldInfos = new ArrayList<>();
-    Set<Integer> tagIds = null;
     for (int i = 0; i < numFields; i++) {
       int header = buffer.readByte() & 0xff;
       //  `3 bits size + 2 bits field name encoding + nullability flag + ref 
tracking flag`
@@ -357,12 +357,6 @@ class NativeTypeDefDecoder {
       // Read field name or tag ID
       String fieldName;
       if (useTagID) {
-        if (tagIds == null) {
-          tagIds = new HashSet<>();
-        }
-        if (!tagIds.add(tagId)) {
-          throw new DeserializationException("Duplicate TypeDef field tag ID " 
+ tagId);
-        }
         // Use placeholder field name since tag ID is used for identification
         fieldName = "$tag" + tagId;
       } else {
@@ -386,6 +380,21 @@ class NativeTypeDefDecoder {
     return fieldInfos;
   }
 
+  private static void validateTagIds(List<FieldInfo> fields) {
+    Set<Integer> tagIds = null;
+    for (FieldInfo field : fields) {
+      if (field.hasFieldId()) {
+        if (tagIds == null) {
+          tagIds = new HashSet<>();
+        }
+        if (!tagIds.add(field.getFieldId())) {
+          throw new DeserializationException(
+              "Duplicate TypeDef field tag ID " + field.getFieldId());
+        }
+      }
+    }
+  }
+
   static String readPkgName(MemoryBuffer buffer) {
     // - Package name encoding(omitted when class is registered):
     //    - encoding algorithm: 
`UTF_8/ALL_TO_LOWER_SPECIAL/LOWER_UPPER_DIGIT_SPECIAL`
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/meta/NativeTypeDefEncoderTest.java
 
b/java/fory-core/src/test/java/org/apache/fory/meta/NativeTypeDefEncoderTest.java
index 8da611b6c..b772cf0b4 100644
--- 
a/java/fory-core/src/test/java/org/apache/fory/meta/NativeTypeDefEncoderTest.java
+++ 
b/java/fory-core/src/test/java/org/apache/fory/meta/NativeTypeDefEncoderTest.java
@@ -518,6 +518,52 @@ public class NativeTypeDefEncoderTest {
         RuntimeException.class, () -> 
TypeDef.readTypeDef(fory.getTypeResolver(), encoded));
   }
 
+  @Test
+  public void testDecodeRejectsInheritedDuplicateTag() {
+    Fory fory =
+        Fory.builder()
+            .withXlang(false)
+            .withMetaShare(true)
+            .withCompatible(false)
+            .requireClassRegistration(false)
+            .build();
+    TypeDef typeDef = TypeDef.buildTypeDef(fory.getTypeResolver(), 
TaggedChild.class);
+    byte[] encoded = typeDef.getEncoded();
+    MemoryBuffer encodedBuffer = MemoryBuffer.fromByteArray(encoded);
+    long header = encodedBuffer.readInt64();
+    byte[] body =
+        NativeTypeDefDecoder.decodeTypeDefBuf(
+                encodedBuffer, (ClassResolver) fory.getTypeResolver(), header)
+            .f0;
+
+    List<FieldInfo> fields =
+        buildFieldsInfo((ClassResolver) fory.getTypeResolver(), 
TaggedChild.class);
+    FieldInfo parentField =
+        fields.stream()
+            .filter(field -> 
field.getDefinedClass().equals(TaggedParent.class.getName()))
+            .findFirst()
+            .orElseThrow(AssertionError::new);
+    FieldInfo childField =
+        fields.stream()
+            .filter(field -> 
field.getDefinedClass().equals(TaggedChild.class.getName()))
+            .findFirst()
+            .orElseThrow(AssertionError::new);
+    byte[] parentFieldBytes = encodedFieldInfo(parentField);
+    byte[] childFieldBytes = encodedFieldInfo(childField);
+    Assert.assertEquals(childFieldBytes.length, parentFieldBytes.length);
+
+    int childFieldOffset = indexOf(body, childFieldBytes, 0);
+    Assert.assertTrue(childFieldOffset >= 0);
+    System.arraycopy(parentFieldBytes, 0, body, childFieldOffset, 
parentFieldBytes.length);
+    MemoryBuffer malformedBody = MemoryBuffer.newHeapBuffer(body.length);
+    malformedBody.writeBytes(body);
+    MemoryBuffer malformed = NativeTypeDefEncoder.prependHeader(malformedBody, 
false);
+
+    Assert.assertThrows(
+        DeserializationException.class,
+        () -> TypeDef.readTypeDef(fory.getTypeResolver(), malformed));
+  }
+
   @Test
   public void testRejectsNamespaceEncoding() {
     Fory fory =
@@ -601,6 +647,12 @@ public class NativeTypeDefEncoderTest {
     return malformed;
   }
 
+  private static byte[] encodedFieldInfo(FieldInfo fieldInfo) {
+    MemoryBuffer buffer = MemoryBuffer.newHeapBuffer(16);
+    NativeTypeDefEncoder.writeFieldsInfo(buffer, 
Collections.singletonList(fieldInfo));
+    return buffer.getBytes(0, buffer.writerIndex());
+  }
+
   private static byte[] rewriteHeaderWithBodyOnlyHash(TypeDef typeDef) {
     byte[] malformed = typeDef.getEncoded().clone();
     MemoryBuffer buffer = MemoryBuffer.fromByteArray(malformed);
@@ -782,6 +834,16 @@ public class NativeTypeDefEncoderTest {
     private String fieldMax;
   }
 
+  public static class TaggedParent {
+    @ForyField(id = 10)
+    private int parentValue;
+  }
+
+  public static class TaggedChild extends TaggedParent {
+    @ForyField(id = 20)
+    private int childValue;
+  }
+
   @Data
   public static class ClassWithMixedFields {
     @ForyField(id = 15)
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/CPPXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/CPPXlangTest.java
index 050fd5d4a..721bda8cb 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/CPPXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/CPPXlangTest.java
@@ -160,6 +160,12 @@ public class CPPXlangTest extends XlangTestBase {
     super.testCrossLanguageSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws 
java.io.IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws 
java.io.IOException {
     super.testSimpleStruct(enableCodegen);
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
index a79167c77..2f971d7b0 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
@@ -155,6 +155,12 @@ public class CSharpXlangTest extends XlangTestBase {
     super.testCrossLanguageSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws 
java.io.IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws 
java.io.IOException {
     super.testSimpleStruct(enableCodegen);
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/DartXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/DartXlangTest.java
index f484a46da..959884e61 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/DartXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/DartXlangTest.java
@@ -245,6 +245,12 @@ public class DartXlangTest extends XlangTestBase {
     super.testCrossLanguageSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws 
java.io.IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws 
java.io.IOException {
     super.testSimpleStruct(enableCodegen);
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/GoXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/GoXlangTest.java
index 98f96d926..f40f6c3d4 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/GoXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/GoXlangTest.java
@@ -124,6 +124,12 @@ public class GoXlangTest extends XlangTestBase {
     super.testCrossLanguageSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws 
java.io.IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws 
java.io.IOException {
     super.testSimpleStruct(enableCodegen);
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/JavaScriptXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/JavaScriptXlangTest.java
index 576bb8da0..4dd06588d 100644
--- 
a/java/fory-core/src/test/java/org/apache/fory/xlang/JavaScriptXlangTest.java
+++ 
b/java/fory-core/src/test/java/org/apache/fory/xlang/JavaScriptXlangTest.java
@@ -282,6 +282,12 @@ public class JavaScriptXlangTest extends XlangTestBase {
     super.testCrossLanguageSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws 
java.io.IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws 
java.io.IOException {
     super.testSimpleStruct(enableCodegen);
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/PythonXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/PythonXlangTest.java
index b1bca830b..4d2ba3935 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/PythonXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/PythonXlangTest.java
@@ -154,6 +154,12 @@ public class PythonXlangTest extends XlangTestBase {
     super.testStringSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Override
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws IOException {
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/RustXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/RustXlangTest.java
index bad2cd90e..43be8356f 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/RustXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/RustXlangTest.java
@@ -130,6 +130,12 @@ public class RustXlangTest extends XlangTestBase {
     super.testCrossLanguageSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws 
java.io.IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws 
java.io.IOException {
     super.testSimpleStruct(enableCodegen);
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/ScalaXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/ScalaXlangTest.java
index 67aa5c1de..d62c858f0 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/ScalaXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/ScalaXlangTest.java
@@ -239,6 +239,12 @@ public class ScalaXlangTest extends XlangTestBase {
     super.testCrossLanguageSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws IOException {
     super.testSimpleStruct(enableCodegen);
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/SwiftXlangTest.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/SwiftXlangTest.java
index 5f64af5a6..e29c2b851 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/SwiftXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/SwiftXlangTest.java
@@ -207,6 +207,12 @@ public class SwiftXlangTest extends XlangTestBase {
     super.testCrossLanguageSerializer();
   }
 
+  @Override
+  @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws 
java.io.IOException {
+    super.testExactFieldTags(enableCodegen);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws 
java.io.IOException {
     super.testSimpleStruct(enableCodegen);
diff --git 
a/java/fory-core/src/test/java/org/apache/fory/xlang/XlangTestBase.java 
b/java/fory-core/src/test/java/org/apache/fory/xlang/XlangTestBase.java
index cbd0a41d1..fff9c46fe 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/XlangTestBase.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/XlangTestBase.java
@@ -654,6 +654,40 @@ public abstract class XlangTestBase extends ForyTestBase {
     int last; // Changed from Integer to int to match Rust
   }
 
+  @Data
+  @ForyStruct
+  static class ExactFieldTags {
+    @ForyField(id = 15)
+    int first;
+
+    @ForyField(id = 65551)
+    int second;
+
+    @ForyField(id = 536870911)
+    int last;
+  }
+
+  @Test(
+      groups = {"xlang", "field-tag"},
+      dataProvider = "enableCodegenParallel")
+  public void testExactFieldTags(boolean enableCodegen) throws IOException {
+    Fory fory =
+        
Fory.builder().withXlang(true).withCompatible(false).withCodegen(enableCodegen).build();
+    fory.register(ExactFieldTags.class, 104);
+    ExactFieldTags value = new ExactFieldTags();
+    value.first = 39;
+    value.second = 40;
+    value.last = 41;
+    serDeCheck(fory, value);
+
+    MemoryBuffer buffer = MemoryUtils.buffer(64);
+    fory.serialize(buffer, value);
+    ExecutionContext ctx =
+        prepareExecution("test_exact_field_tags", buffer.getBytes(0, 
buffer.writerIndex()));
+    runPeer(ctx);
+    Assert.assertEquals(fory.deserialize(readBuffer(ctx.dataFile())), value);
+  }
+
   @Test(groups = "xlang", dataProvider = "enableCodegenParallel")
   public void testSimpleStruct(boolean enableCodegen) throws 
java.io.IOException {
     String caseName = "test_simple_struct";
diff --git a/javascript/test/crossLanguage.test.ts 
b/javascript/test/crossLanguage.test.ts
index 32f7729b5..6b700c7e9 100644
--- a/javascript/test/crossLanguage.test.ts
+++ b/javascript/test/crossLanguage.test.ts
@@ -364,6 +364,27 @@ describe("bool", () => {
 
     writeToFile(serializedData as Buffer);
   });
+  test("test_exact_field_tags", () => {
+    const fory = new Fory({
+      compatible: false,
+    });
+
+    @Type.struct(104, {
+      first: Type.int32().setId(15),
+      second: Type.int32().setId(65551),
+      last: Type.int32().setId(536870911),
+    })
+    class ExactFieldTags {
+      first: number = 0;
+      second: number = 0;
+      last: number = 0;
+    }
+    fory.register(ExactFieldTags);
+
+    const value = fory.deserialize(content) as ExactFieldTags;
+    expect(value).toEqual({ first: 39, second: 40, last: 41 });
+    writeToFile(fory.serialize(value) as Buffer);
+  });
   test("test_named_simple_struct", () => {
     // Same as test_simple_struct but with named registration
     const fory = new Fory({
diff --git 
a/kotlin/fory-kotlin-ksp/src/test/kotlin/org/apache/fory/kotlin/ksp/ProcessorValidationTest.kt
 
b/kotlin/fory-kotlin-ksp/src/test/kotlin/org/apache/fory/kotlin/ksp/ProcessorValidationTest.kt
index 0f5ba639a..8d705bcae 100644
--- 
a/kotlin/fory-kotlin-ksp/src/test/kotlin/org/apache/fory/kotlin/ksp/ProcessorValidationTest.kt
+++ 
b/kotlin/fory-kotlin-ksp/src/test/kotlin/org/apache/fory/kotlin/ksp/ProcessorValidationTest.kt
@@ -80,10 +80,8 @@ class ProcessorValidationTest {
   }
 
   @Test
-  fun validatesFieldIds() {
+  fun rejectsInvalidFieldIds() {
     assertNull(fieldIdError(-1, requireFieldId = false))
-    assertNull(fieldIdError(0, requireFieldId = true))
-    assertNull(fieldIdError(536_870_911, requireFieldId = true))
     assertNotNull(fieldIdError(-1, requireFieldId = true))
     assertNotNull(fieldIdError(536_870_912, requireFieldId = true))
   }
diff --git 
a/kotlin/fory-kotlin-tests/src/main/kotlin/org/apache/fory/kotlin/xlang/KotlinXlangPeer.kt
 
b/kotlin/fory-kotlin-tests/src/main/kotlin/org/apache/fory/kotlin/xlang/KotlinXlangPeer.kt
index 05c7e66b3..92e190411 100644
--- 
a/kotlin/fory-kotlin-tests/src/main/kotlin/org/apache/fory/kotlin/xlang/KotlinXlangPeer.kt
+++ 
b/kotlin/fory-kotlin-tests/src/main/kotlin/org/apache/fory/kotlin/xlang/KotlinXlangPeer.kt
@@ -65,6 +65,9 @@ constructor(
   @ForyField(id = 3) val score: @VarInt Long,
 )
 
+@ForyStruct
+public data class KotlinMaximumFieldTag constructor(@ForyField(id = 
536_870_911) val value: Int)
+
 @ForyStruct
 internal data class KotlinInternalUser
 constructor(
@@ -305,6 +308,7 @@ private fun staticSerializerRoundTrip(dataFile: String) {
 
   val fory = newFory()
   fory.register<KotlinUser>("kotlin.KotlinUser")
+  fory.register<KotlinMaximumFieldTag>("kotlin.KotlinMaximumFieldTag")
   fory.register<KotlinInternalUser>("kotlin.KotlinInternalUser")
   fory.register<KotlinConcreteCollections>("kotlin.KotlinConcreteCollections")
   fory.register<KotlinUnsignedCollections>("kotlin.KotlinUnsignedCollections")
@@ -333,6 +337,14 @@ private fun staticSerializerRoundTrip(dataFile: String) {
   check(descriptors[0].typeRef.typeExtMeta.typeId() == Types.UINT32)
   check(descriptors[2].typeRef.typeExtMeta.typeId() == Types.VARINT64)
 
+  val maximumTag = KotlinMaximumFieldTag(42)
+  check(
+    fory.deserialize(fory.serialize(maximumTag), 
KotlinMaximumFieldTag::class.java) == maximumTag
+  )
+  val maximumTagSerializer = 
fory.getSerializer(KotlinMaximumFieldTag::class.java)
+  check(maximumTagSerializer is StaticGeneratedStructSerializer<*>)
+  check(maximumTagSerializer.generatedDescriptors.single().foryFieldId == 
536_870_911)
+
   val internalUser = KotlinInternalUser(id = UInt.MAX_VALUE, name = 
"internal-static")
   check(
     fory.deserialize(fory.serialize(internalUser), 
KotlinInternalUser::class.java) == internalUser
diff --git a/python/pyfory/field.py b/python/pyfory/field.py
index e723ac548..69357c437 100644
--- a/python/pyfory/field.py
+++ b/python/pyfory/field.py
@@ -51,7 +51,7 @@ class ForyFieldMeta:
     Fory field metadata extracted from field.metadata.
 
     Attributes:
-        id: Field tag ID. -1 is the internal sentinel for no configured ID; 
>=0 means use tag ID.
+        id: Field tag ID in [0, 2^29), or -1 as the internal no-ID sentinel.
         nullable: Whether null flag is written. Default False.
         ref: Whether reference tracking is enabled for this field. Default 
False.
         ignore: Whether to ignore this field during serialization. Default 
False.
@@ -129,8 +129,9 @@ def field(
     Args:
         id: Field tag ID (optional).
             - omitted: Use field name with meta string encoding
-            - >=0: Use numeric tag ID (more compact, stable across renames)
-            Must be unique within the class. Negative configured IDs are 
invalid.
+            - 0 <= id < 2^29: Use numeric tag ID (more compact, stable across 
renames)
+            Must be unique within the class. Negative configured IDs and values
+            greater than or equal to 2^29 are invalid.
 
         nullable: Whether to write null flag for this field.
             - False (default): Skip null flag, field cannot be None
diff --git a/python/pyfory/tests/xlang_test_main.py 
b/python/pyfory/tests/xlang_test_main.py
index 49d16d2a3..e56f08fe4 100644
--- a/python/pyfory/tests/xlang_test_main.py
+++ b/python/pyfory/tests/xlang_test_main.py
@@ -114,6 +114,13 @@ class SimpleStruct:
     last: pyfory.Int32 = pyfory.field(536870911, default=0)
 
 
+@dataclass
+class ExactFieldTags:
+    first: pyfory.Int32 = pyfory.field(15, default=0)
+    second: pyfory.Int32 = pyfory.field(65551, default=0)
+    last: pyfory.Int32 = pyfory.field(536870911, default=0)
+
+
 @pyfory.dataclass
 class EvolvingOverrideStruct:
     f1: str = ""
@@ -501,6 +508,20 @@ def test_simple_struct():
         f.write(new_bytes)
 
 
+def test_exact_field_tags():
+    """Round-trip the complete field-tag boundary set in schema-consistent 
mode."""
+    data_file = get_data_file()
+    with open(data_file, "rb") as f:
+        data_bytes = f.read()
+
+    fory = pyfory.Fory(xlang=True, compatible=False)
+    fory.register_type(ExactFieldTags, type_id=104)
+    value = fory.deserialize(data_bytes)
+    assert value == ExactFieldTags(first=39, second=40, last=41)
+    with open(data_file, "wb") as f:
+        f.write(fory.serialize(value))
+
+
 def test_named_simple_struct():
     """Test named simple struct serialization."""
     data_file = get_data_file()
diff --git a/rust/fory-core/src/meta/type_meta.rs 
b/rust/fory-core/src/meta/type_meta.rs
index cbba622c8..f30d7032b 100644
--- a/rust/fory-core/src/meta/type_meta.rs
+++ b/rust/fory-core/src/meta/type_meta.rs
@@ -424,7 +424,7 @@ impl FieldType {
     }
 }
 
-#[derive(Debug, PartialEq, Eq, Clone)]
+#[derive(Debug, Clone)]
 pub struct FieldInfo {
     /// Protocol field tag, or `-1` when the field is identified by name.
     pub field_id: i32,
@@ -435,6 +435,17 @@ pub struct FieldInfo {
     pub field_type: FieldType,
 }
 
+impl PartialEq for FieldInfo {
+    fn eq(&self, other: &Self) -> bool {
+        // matched_field_id is derived local dispatch state, not protocol 
metadata identity.
+        self.field_id == other.field_id
+            && self.field_name == other.field_name
+            && self.field_type == other.field_type
+    }
+}
+
+impl Eq for FieldInfo {}
+
 impl FieldInfo {
     pub fn new(field_name: &str, field_type: FieldType) -> FieldInfo {
         FieldInfo {
@@ -1476,9 +1487,11 @@ mod tests {
         let remote_type = FieldType::new(crate::type_id::INT16, false, vec![]);
         let local_fields = [FieldInfo::new("value", local_type)];
         let mut remote_fields = [FieldInfo::new("value", remote_type)];
+        let wire_field = remote_fields[0].clone();
 
         match_remote_fields(&local_fields, &mut remote_fields).unwrap();
 
+        assert_eq!(remote_fields[0], wire_field);
         assert_eq!(remote_fields[0].field_id, -1);
         assert_eq!(remote_fields[0].matched_field_id, 1);
     }
diff --git a/rust/tests/tests/test_cross_language.rs 
b/rust/tests/tests/test_cross_language.rs
index b94921ba4..45ee5b1d9 100644
--- a/rust/tests/tests/test_cross_language.rs
+++ b/rust/tests/tests/test_cross_language.rs
@@ -72,6 +72,16 @@ struct SimpleStruct {
     last: i32,
 }
 
+#[derive(ForyStruct, Debug, PartialEq)]
+struct ExactFieldTags {
+    #[fory(id = 15)]
+    first: i32,
+    #[fory(id = 65551)]
+    second: i32,
+    #[fory(id = 536870911)]
+    last: i32,
+}
+
 #[derive(ForyStruct, Debug, PartialEq, Default)]
 struct EvolvingOverrideStruct {
     f1: String,
@@ -448,6 +458,24 @@ fn test_simple_struct() {
     fs::write(&data_file_path, new_bytes).unwrap();
 }
 
+#[test]
+#[ignore]
+fn test_exact_field_tags() {
+    let data_file_path = get_data_file();
+    let bytes = fs::read(&data_file_path).unwrap();
+    let mut fory = Fory::builder().compatible(false).xlang(true).build();
+    fory.register::<ExactFieldTags>(104).unwrap();
+
+    let expected = ExactFieldTags {
+        first: 39,
+        second: 40,
+        last: 41,
+    };
+    let value: ExactFieldTags = fory.deserialize(&bytes).unwrap();
+    assert_eq!(value, expected);
+    fs::write(&data_file_path, fory.serialize(&value).unwrap()).unwrap();
+}
+
 #[test]
 #[ignore]
 fn test_named_simple_struct() {
diff --git 
a/scala/fory-scala/src/test/scala-3/org/apache/fory/serializer/scala/ScalaXlangPeer.scala
 
b/scala/fory-scala/src/test/scala-3/org/apache/fory/serializer/scala/ScalaXlangPeer.scala
index 9b223a66d..0ed3f6a18 100644
--- 
a/scala/fory-scala/src/test/scala-3/org/apache/fory/serializer/scala/ScalaXlangPeer.scala
+++ 
b/scala/fory-scala/src/test/scala-3/org/apache/fory/serializer/scala/ScalaXlangPeer.scala
@@ -94,6 +94,13 @@ final case class SimpleStruct(
     @ForyField(id = 536870911) last: Int)
     derives ForySerializer
 
+@ForyStruct
+final case class ExactFieldTags(
+    @ForyField(id = 15) first: Int,
+    @ForyField(id = 65551) second: Int,
+    @ForyField(id = 536870911) last: Int)
+    derives ForySerializer
+
 @ForyStruct(evolution = Evolution.ENABLED)
 final case class EvolvingOverrideStruct(f1: String) derives ForySerializer
 
@@ -370,6 +377,7 @@ object ScalaXlangPeer {
       case "test_string_serializer" => roundTripValues(dataFile, newFory())
       case "test_cross_language_serializer" => roundTripValues(dataFile, 
crossLanguageFory())
       case "test_simple_struct" => roundTripValues(dataFile, 
simpleStructFory(false))
+      case "test_exact_field_tags" => roundTripValues(dataFile, 
exactFieldTagsFory())
       case "test_named_simple_struct" => roundTripValues(dataFile, 
simpleStructFory(true))
       case "test_struct_evolving_override" => roundTripValues(dataFile, 
evolvingOverrideFory())
       case "test_list" | "test_map" | "test_item" => roundTripValues(dataFile, 
itemFory())
@@ -582,6 +590,12 @@ object ScalaXlangPeer {
     fory
   }
 
+  private def exactFieldTagsFory(): Fory = {
+    val fory = newFory(compatible = false)
+    registerStruct(fory, classOf[ExactFieldTags], 104L)
+    fory
+  }
+
   private def evolvingOverrideFory(): Fory = {
     val fory = newFory()
     registerStruct(fory, classOf[EvolvingOverrideStruct], "test", 
"evolving_yes")
diff --git a/swift/Tests/ForyXlangTests/main.swift 
b/swift/Tests/ForyXlangTests/main.swift
index ccdf521f4..db503d40a 100644
--- a/swift/Tests/ForyXlangTests/main.swift
+++ b/swift/Tests/ForyXlangTests/main.swift
@@ -59,6 +59,16 @@ private struct SimpleStruct {
     var last: Int32 = 0
 }
 
+@ForyStruct
+private struct ExactFieldTags {
+    @ForyField(id: 15)
+    var first: Int32 = 0
+    @ForyField(id: 65551)
+    var second: Int32 = 0
+    @ForyField(id: 536870911)
+    var last: Int32 = 0
+}
+
 @ForyStruct
 private struct EvolvingOverrideStruct {
     var f1: String = ""
@@ -695,6 +705,12 @@ private func handleSimpleStruct(_ bytes: [UInt8]) throws 
-> [UInt8] {
     return try roundTripSingle(bytes, fory: fory, as: SimpleStruct.self)
 }
 
+private func handleExactFieldTags(_ bytes: [UInt8]) throws -> [UInt8] {
+    let fory = Fory(config: .init(trackRef: false, compatible: false))
+    try fory.register(ExactFieldTags.self, id: 104)
+    return try roundTripSingle(bytes, fory: fory, as: ExactFieldTags.self)
+}
+
 private func handleNamedSimpleStruct(_ bytes: [UInt8]) throws -> [UInt8] {
     let fory = Fory(config: .init(trackRef: false, compatible: true))
     try fory.register(PeerColor.self, name: "demo.color")
@@ -1168,6 +1184,8 @@ private func rewritePayload(caseName: String, bytes: 
[UInt8]) throws -> [UInt8]
         return try handleCrossLanguageSerializer(bytes)
     case "test_simple_struct":
         return try handleSimpleStruct(bytes)
+    case "test_exact_field_tags":
+        return try handleExactFieldTags(bytes)
     case "test_named_simple_struct":
         return try handleNamedSimpleStruct(bytes)
     case "test_struct_evolving_override":


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to