diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x
index 5464a3af..e7ab53f8 100644
--- a/release-notes/VERSION-2.x
+++ b/release-notes/VERSION-2.x
@@ -14,6 +14,11 @@ NOTE: Jackson 3.x components rely on 2.x annotations; there are no separate
=== Releases ===
------------------------------------------------------------------------
+(not yet released)
+
+#346: Add `@JsonWrapped` annotation for grouping bean properties into a
+ nested JSON object (inverse of `@JsonUnwrapped`)
+
2.22 (not yet released)
#78: Add `@JsonApplyView` to allow changing active JsonView on submodels
diff --git a/src/main/java/com/fasterxml/jackson/annotation/JsonWrapped.java b/src/main/java/com/fasterxml/jackson/annotation/JsonWrapped.java
new file mode 100644
index 00000000..ed111abc
--- /dev/null
+++ b/src/main/java/com/fasterxml/jackson/annotation/JsonWrapped.java
@@ -0,0 +1,82 @@
+package com.fasterxml.jackson.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation that groups one or more bean properties into a synthetic
+ * nested JSON object during serialization, and extracts them back during
+ * deserialization. This is the inverse of {@link JsonUnwrapped}.
+ *
+ *
Multiple fields annotated with the same {@code value()} are grouped into
+ * a single wrapper object. Inner property names follow Jackson's standard naming
+ * ({@code @JsonProperty} or default).
+ *
+ *
Example: given a POJO such as:
+ *
+ * public class Gene {
+ * public String name;
+ *
+ * @JsonWrapped("chr")
+ * public String chromosome;
+ *
+ * @JsonWrapped("chr")
+ * public int position;
+ * }
+ *
+ * serialization produces:
+ *
+ * {
+ * "name" : "BRCA1",
+ * "chr" : {
+ * "chromosome" : "17",
+ * "position" : 43044295
+ * }
+ * }
+ *
+ *
+ * Constraints:
+ *
+ * - Non-scalar field types (POJOs, collections, maps, arrays) are supported for
+ * baseline serialization and deserialization. Each inner field serializes under
+ * its own name within the wrapper object. Note: existing interaction limitations
+ * around {@code @JsonView}, {@code @JsonFilter}, and {@code @JsonInclude} on
+ * inner wrapped fields still apply — see the remaining bullets below.
+ * - To disable wrapping via a mix-in annotation, use {@code enabled=false}
+ * (e.g. {@code @JsonWrapped(value="name", enabled=false)}); this is the standard
+ * Jackson override idiom. Alternatively, an empty {@code value()} ({@code @JsonWrapped("")})
+ * also disables wrapping.
+ * - The wrapper name must not conflict with an existing non-wrapped property on the same bean.
+ * - Not supported on {@code @JsonCreator} constructor or factory-method parameters.
+ * - MVP limitation: {@code @JsonView} on inner wrapped fields is ignored — the wrapper
+ * is always emitted and all inner fields are always included regardless of active view.
+ * - MVP limitation: class-level {@code @JsonFilter} still applies to the wrapper property
+ * by its wrapper name (the whole wrapper can be suppressed if the filter excludes it),
+ * but inner fields are not individually filtered.
+ * - MVP limitation: class-level {@code @JsonInclude} (e.g. {@code NON_NULL}) still applies
+ * to inner wrapped fields during serialization.
+ *
+ *
+ * @see JsonUnwrapped
+ * @since 2.22
+ */
+@Target({ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
+@Retention(RetentionPolicy.RUNTIME)
+@JacksonAnnotation
+public @interface JsonWrapped {
+ /**
+ * Single-level wrapper object name (e.g. "chr").
+ * An empty string disables wrapping (useful in mix-ins to suppress
+ * wrapping defined in a supertype).
+ */
+ String value();
+
+ /**
+ * Property that is usually only used when overriding (masking) annotations,
+ * using mix-in annotations. Otherwise default value of {@code true} is fine,
+ * and value need not be explicitly included.
+ */
+ boolean enabled() default true;
+}
diff --git a/src/test/java/com/fasterxml/jackson/annotation/JsonWrappedTest.java b/src/test/java/com/fasterxml/jackson/annotation/JsonWrappedTest.java
new file mode 100644
index 00000000..8d243d3d
--- /dev/null
+++ b/src/test/java/com/fasterxml/jackson/annotation/JsonWrappedTest.java
@@ -0,0 +1,53 @@
+package com.fasterxml.jackson.annotation;
+
+import java.lang.reflect.Constructor;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class JsonWrappedTest
+ extends AnnotationTestUtil
+{
+ private static class BeanWithField {
+ @JsonWrapped("wrapper")
+ public String field;
+ }
+
+ private static class BeanWithMethod {
+ @JsonWrapped("wrapper")
+ public String getField() { return null; }
+ }
+
+ private static class BeanWithParam {
+ public BeanWithParam(@JsonWrapped("wrapper") String field) { }
+ }
+
+ @JsonWrapped("wrapper")
+ @JacksonAnnotationsInside
+ @interface BundleAnnotation { }
+
+ @Test
+ public void testRuntimeRetentionOnField() throws Exception {
+ JsonWrapped ann = BeanWithField.class.getField("field").getAnnotation(JsonWrapped.class);
+ assertNotNull(ann);
+ assertTrue(ann.enabled());
+ }
+
+ @Test
+ public void testRuntimeRetentionOnMethod() throws Exception {
+ JsonWrapped ann = BeanWithMethod.class.getMethod("getField").getAnnotation(JsonWrapped.class);
+ assertNotNull(ann);
+ }
+
+ @Test
+ public void testApplicableOnConstructorParameter() throws Exception {
+ Constructor> ctor = BeanWithParam.class.getDeclaredConstructor(String.class);
+ JsonWrapped ann = ctor.getParameters()[0].getAnnotation(JsonWrapped.class);
+ assertNotNull(ann);
+ }
+
+ @Test
+ public void testApplicableOnAnnotationType() {
+ JsonWrapped ann = BundleAnnotation.class.getAnnotation(JsonWrapped.class);
+ assertNotNull(ann);
+ }
+}