diff --git a/src/box/lua/config/utils/schema.lua b/src/box/lua/config/utils/schema.lua
index 5b21eb27567681712f4127fff1a520236c5abc22..8f5a74fb59d4eba10437019b4233bada109ba856 100644
--- a/src/box/lua/config/utils/schema.lua
+++ b/src/box/lua/config/utils/schema.lua
@@ -1389,6 +1389,157 @@ end
 
 -- }}} <schema object>:pairs()
 
+-- {{{ Schema preprocessing
+
+-- Step down to a schema node.
+--
+-- The function performs annotations tracking.
+--
+-- The annotations are tracked for all the nodes up to the root
+-- forming a stack of annotations. It allows to step down to a
+-- child node using preprocess_enter(), step up to the original
+-- node using preprocess_leave() and step down again into another
+-- child node.
+--
+-- At each given point the top annotation frame represents
+-- annotations merged from the root node down to the given one.
+-- If there are same named annotations on the path, then one from
+-- a descendant node is preferred.
+local function preprocess_enter(ctx, schema)
+    assert(ctx.annotation_stack ~= nil)
+
+    -- These keys are part of the schema node tree structure
+    -- itself. See the 'Schema nodes constructors' section in this
+    -- file for details about the schema node structure.
+    local non_annotation_keys = {
+        type = true,
+        fields = true,
+        key = true,
+        value = true,
+        items = true,
+    }
+
+    -- There are known annotations that barely has any sense in
+    -- context of descendant schema nodes. Don't track them.
+    local ignored_annotations = {
+        allowed_values = true,
+        validate = true,
+        default = true,
+        apply_default_if = true,
+    }
+
+    local frame = table.copy(ctx.annotation_stack[#ctx.annotation_stack] or {})
+    for k, v in pairs(schema) do
+        if not non_annotation_keys[k] and not ignored_annotations[k] then
+            frame[k] = v
+        end
+    end
+    table.insert(ctx.annotation_stack, frame)
+end
+
+-- Step up from a schema node.
+--
+-- Returns the computed fields for the node that we're leaving.
+--
+-- See preprocess_enter() for details.
+local function preprocess_leave(ctx)
+    return {
+        annotations = table.remove(ctx.annotation_stack),
+    }
+end
+
+-- The function prepares the given schema node tree in the
+-- following way.
+--
+-- * The schema node is copied.
+-- * All its descendant nodes are prepared using this algorithm
+--   (recursively).
+-- * Computed fields are calculated and stored in the copied node.
+-- * The copied node is the result.
+--
+-- The sketchy structure of the returned schema node is the
+-- following.
+--
+-- {
+--     <..fields..>
+--     computed = {
+--         annotations = <...>,
+--     },
+-- }
+--
+-- At now there is only one computed field: `annotations`.
+--
+-- The `annotations` field contains all the annotations merged
+-- from the root schema node down to the given one. If the same
+-- annotation is present in an ancestor node and in an descendant
+-- node, the latter is preferred.
+--
+-- Design details
+-- --------------
+--
+-- The copying is performed to decouple schema node definitions
+-- provided by a caller from the schema nodes stored in the schema
+-- object. It allows to modify them without modifying caller's own
+-- objects.
+--
+-- It is especially important if parts of one schema are passed
+-- as schema node definitions to create another schema.
+--
+-- This case also motivates to store all the computed fields in
+-- the `computed` field. This way it is easier to guarantee that
+-- all the computed fields are stripped down from the original
+-- schema nodes and don't affect the new schema anyhow.
+local function preprocess_schema(schema, ctx)
+    -- A schema node from another (already constructed) schema may
+    -- be used in the schema that is currently under construction.
+    --
+    -- Since we're going to modify the schema node, we should copy
+    -- it beforehand. Our modifications must not affect another
+    -- schema.
+    local res = table.copy(schema)
+
+    -- Eliminate previously computed values if any.
+    --
+    -- The past values were computed against node's place in
+    -- another schema, so it must not influence the node in its
+    -- current place in the given schema.
+    --
+    -- This field is rewritten at end of the function, but it
+    -- anyway should be stripped before going to
+    -- preprocess_enter(). Otherwise it would be taken as an
+    -- annotation.
+    --
+    -- (It can be just ignored in preprocess_enter(), but dropping
+    -- it beforehand looks less error-prone.)
+    res.computed = nil
+
+    preprocess_enter(ctx, res)
+
+    -- luacheck: ignore 542 empty if branch
+    if is_scalar(schema) then
+        -- Nothing to do.
+    elseif schema.type == 'record' then
+        local fields = {}
+        for field_name, field_def in pairs(schema.fields) do
+            fields[field_name] = preprocess_schema(field_def, ctx)
+        end
+        res.fields = fields
+    elseif schema.type == 'map' then
+        res.key = preprocess_schema(schema.key, ctx)
+        res.value = preprocess_schema(schema.value, ctx)
+    elseif schema.type == 'array' then
+        res.items = preprocess_schema(schema.items, ctx)
+    else
+        assert(false)
+    end
+
+    res.computed = preprocess_leave(ctx)
+
+    return res
+end
+
+-- }}} Schema preprocessing
+
 -- {{{ Schema object constructor: new
 
 -- Define a field lookup function on a schema object.
@@ -1421,9 +1572,14 @@ local function new(name, schema, opts)
     assert(type(name) == 'string')
     assert(type(schema) == 'table')
 
+    local ctx = {
+        annotation_stack = {},
+    }
+    local preprocessed_schema = preprocess_schema(schema, ctx)
+
     return setmetatable({
         name = name,
-        schema = schema,
+        schema = preprocessed_schema,
         methods = instance_methods,
     }, schema_mt)
 end
diff --git a/test/config-luatest/config_test.lua b/test/config-luatest/config_test.lua
index 5bd5654d213ec862f36d1ee8e451ea6b202b1a92..785a478d5ea4735a89c7b7f048b3c7b33a89a8c3 100644
--- a/test/config-luatest/config_test.lua
+++ b/test/config-luatest/config_test.lua
@@ -99,6 +99,12 @@ g.test_configdata = function()
                 box_cfg = "sql_cache_size",
                 default = 5242880,
                 type = "integer",
+                computed = {
+                    annotations = {
+                        config_version = "dev",
+                        box_cfg = "sql_cache_size",
+                    },
+                },
             },
         },
         {
@@ -108,6 +114,12 @@ g.test_configdata = function()
                 box_cfg = "memtx_memory",
                 default = 268435456,
                 type = "integer",
+                computed = {
+                    annotations = {
+                        config_version = "dev",
+                        box_cfg = "memtx_memory",
+                    },
+                },
             },
         },
     }
diff --git a/test/config-luatest/schema_test.lua b/test/config-luatest/schema_test.lua
index ebdb7c0288359bf9fedfb689a2dce1c809da4c0e..91de71e126a28826d1b0344b162a7afa225b11f5 100644
--- a/test/config-luatest/schema_test.lua
+++ b/test/config-luatest/schema_test.lua
@@ -329,6 +329,30 @@ end
 
 -- }}} Derived schema node type constructors: enum, set
 
+-- {{{ Testing helpers for comparing schema nodes
+
+local function remove_computed_fields(schema)
+    local res = table.copy(schema)
+    res.computed = nil
+
+    if schema.type == 'record' then
+        local fields = {}
+        for k, v in pairs(res.fields) do
+            fields[k] = remove_computed_fields(v)
+        end
+        res.fields = fields
+    elseif schema.type == 'array' then
+        res.items = remove_computed_fields(res.items)
+    elseif schema.type == 'map' then
+        res.key = remove_computed_fields(res.key)
+        res.value = remove_computed_fields(res.value)
+    end
+
+    return res
+end
+
+-- }}} Testing helpers for comparing schema nodes
+
 -- {{{ Schema object constructor: new
 
 -- schema.new() must return a table of the following shape.
@@ -357,7 +381,7 @@ g.test_schema_new = function()
         local name = 'myschema'
         local s = schema.new(name, schema_node)
         t.assert_equals(s.name, name)
-        t.assert_equals(s.schema, schema_node)
+        t.assert_equals(remove_computed_fields(s.schema), schema_node)
         t.assert_equals(s.methods, {})
         t.assert(getmetatable(s) ~= nil)
 
@@ -385,6 +409,145 @@ g.test_schema_new = function()
     t.assert_equals((pcall(schema.new, {}, scalar_1)), false)
 end
 
+-- Verify that if a schema node from one schema is used in another
+-- schema, computed fields from the former schema don't affect
+-- computed fields from the latter one.
+g.test_schema_node_reuse = function()
+    local s1 = schema.new('s1', schema.record({
+        foo = schema.scalar({
+            type = 'string',
+            my_annotation_2 = 2,
+        }),
+    }, {
+        my_annotation_1 = 1,
+    }))
+
+    local s2 = schema.new('s2', schema.record({
+        bar = s1.schema.fields.foo,
+    }, {
+        my_annotation_3 = 3,
+    }))
+
+    t.assert_equals(s2.schema.fields.bar.computed.annotations, {
+        -- Key property: there is no my_annotation_1 here.
+        my_annotation_2 = 2,
+        my_annotation_3 = 3,
+    })
+end
+
+-- Verify computed annotations in schema nodes.
+g.test_computed_annotations = function()
+    local s = schema.new('myschema', schema.record({
+        foo = schema.map({
+            key = schema.scalar({
+                type = 'string',
+                level_3 = 3,
+                clash = 'c',
+            }),
+            value = schema.array({
+                items = schema.scalar({
+                    type = 'integer',
+                    level_4 = 4,
+                    clash = 'd',
+                }),
+                level_3 = 3,
+                clash = 'c',
+            }),
+            level_2 = 2,
+            clash = 'b',
+        }),
+    }, {
+        level_1 = 1,
+        clash = 'a',
+    }))
+
+    t.assert_equals(s.schema, schema.record({
+        foo = schema.map({
+            key = schema.scalar({
+                type = 'string',
+                level_3 = 3,
+                clash = 'c',
+                computed = {
+                    annotations = {
+                        level_1 = 1,
+                        level_2 = 2,
+                        level_3 = 3,
+                        clash = 'c',
+                    },
+                },
+            }),
+            value = schema.array({
+                items = schema.scalar({
+                    type = 'integer',
+                    level_4 = 4,
+                    clash = 'd',
+                    computed = {
+                        annotations = {
+                            level_1 = 1,
+                            level_2 = 2,
+                            level_3 = 3,
+                            level_4 = 4,
+                            clash = 'd',
+                        },
+                    },
+                }),
+                level_3 = 3,
+                clash = 'c',
+                computed = {
+                    annotations = {
+                        level_1 = 1,
+                        level_2 = 2,
+                        level_3 = 3,
+                        clash = 'c',
+                    },
+                },
+            }),
+            level_2 = 2,
+            clash = 'b',
+            computed = {
+                annotations = {
+                    level_1 = 1,
+                    level_2 = 2,
+                    clash = 'b',
+                },
+            },
+        }),
+    }, {
+        level_1 = 1,
+        clash = 'a',
+        computed = {
+            annotations = {
+                level_1 = 1,
+                clash = 'a',
+            },
+        },
+    }))
+end
+
+-- Verify that the following fields are ignored when collecting
+-- computed annotations.
+--
+-- * allowed_values
+-- * validate
+-- * default
+-- * apply_default_if
+g.test_computed_annotations_ignore = function()
+    local s = schema.new('myschema', schema.scalar({
+        type = 'string',
+        allowed_values = {'x'},
+        validate = function() end,
+        default = 'x',
+        apply_default_if = function() return true end,
+        my_annotation = 'my annotation',
+    }))
+
+    t.assert_equals(s.schema.computed, {
+        annotations = {
+            my_annotation = 'my annotation',
+        },
+    })
+end
+
 -- }}} Schema object constructor: new
 
 -- {{{ Testing helpers for <schema object>:validate()
@@ -718,7 +881,7 @@ g.test_validate_by_node_function = function()
 
     -- Verify that the schema node is the one that contains the
     -- `validate` annotation.
-    t.assert_equals(schema_saved, scalar)
+    t.assert_equals(remove_computed_fields(schema_saved), scalar)
 
     -- Verify that the path is not changed during the traversal
     -- and still points to the given schema node.
@@ -2609,7 +2772,7 @@ g.test_pairs = function()
     }))
 
     local res = s:pairs():map(function(w)
-        return {w.schema, w.path}
+        return {remove_computed_fields(w.schema), w.path}
     end):totable()
     t.assert_items_equals(res, {
         {str, {'str'}},