diff --git a/modules/general/json/src/main/scala/oxygen/json/JsonEncoder.scala b/modules/general/json/src/main/scala/oxygen/json/JsonEncoder.scala index a04d19db..a7bd4d1f 100644 --- a/modules/general/json/src/main/scala/oxygen/json/JsonEncoder.scala +++ b/modules/general/json/src/main/scala/oxygen/json/JsonEncoder.scala @@ -61,6 +61,8 @@ trait JsonEncoder[A] { def secret: JsonEncoder.Secret[A] = JsonEncoder.Secret.fromJsonEncoder(this) + def omit: JsonEncoder.Omit[A] = JsonEncoder.Omit.fromJsonEncoder(this) + } object JsonEncoder extends Derivable[JsonEncoder.ObjectEncoder], JsonEncoderLowPriority.LowPriority1 { @@ -373,6 +375,40 @@ object JsonEncoder extends Derivable[JsonEncoder.ObjectEncoder], JsonEncoderLowP } } + ////////////////////////////////////////////////////////////////////////////////////////////////////// + // Omit + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * An encoder which unconditionally omits its field from an enclosing JSON object (`addToObject = false`). + * + * Unlike [[Secret]] (which redirects a field into the "secret" channel), an omitted field never appears + * on the wire at all. When used as a top-level/non-object encoder, it still encodes normally via the + * underlying encoder; the omission only takes effect at the object-field level. + */ + sealed trait Omit[A] extends JsonEncoder[A] { + override final def addToObject(value: A): Boolean = false + override def contramap[B](f: B => A): JsonEncoder.Omit[B] = JsonEncoder.Omit.Contramapped(this, f) + override def omit: JsonEncoder.Omit[A] = this + } + object Omit { + + def fromJsonEncoder[A](underlying: JsonEncoder[A]): JsonEncoder.Omit[A] = underlying match + case underlying: JsonEncoder.Omit[A] => underlying + case underlying => JsonEncoder.Omit.OmitEncoder(underlying) + + final case class OmitEncoder[A] private[Omit] (underlying: JsonEncoder[A]) extends JsonEncoder.Omit[A] { + override def encodeJsonAST(value: A): Json = underlying.encodeJsonAST(value) + override def encodeSplitJsonAST(value: A): Ior[PlainTextJson, SecretJson] = underlying.encodeSplitJsonAST(value) + } + + final case class Contramapped[A, B](encoder: JsonEncoder.Omit[A], f: B => A) extends JsonEncoder.Omit[B] { + override def encodeJsonAST(value: B): Json = encoder.encodeJsonAST(f(value)) + override def encodeSplitJsonAST(value: B): Ior[PlainTextJson, SecretJson] = encoder.encodeSplitJsonAST(f(value)) + } + + } + ////////////////////////////////////////////////////////////////////////////////////////////////////// // Generic ////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -390,8 +426,10 @@ object JsonEncoder extends Derivable[JsonEncoder.ObjectEncoder], JsonEncoderLowP valType = ValDef.ValType.LazyVal, ) { [b] => (_, _) ?=> (field: generic.Field[b]) => val baseInstance: Expr[JsonEncoder[b]] = field.summonTypeClass[JsonEncoder] + val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty val isPlain: Boolean = field.annotations.optionalOf[jsonSecret].isEmpty - if isPlain then baseInstance + if isOmitted then '{ $baseInstance.omit } + else if isPlain then baseInstance else '{ $baseInstance.secret } } } { DeriveProductJsonEncoder[A](_) } diff --git a/modules/general/json/src/main/scala/oxygen/json/OmitValue.scala b/modules/general/json/src/main/scala/oxygen/json/OmitValue.scala new file mode 100644 index 00000000..10509908 --- /dev/null +++ b/modules/general/json/src/main/scala/oxygen/json/OmitValue.scala @@ -0,0 +1,28 @@ +package oxygen.json + +/** + * Wrapper type carrying "omit from JSON" semantics, parallel to [[SecretValue]]. + * + * A field typed `OmitValue[A]` is never written to an enclosing JSON object (the derived + * [[JsonEncoder]] uses `addToObject = false`). On decode the field is reconstructed from its + * constructor default (or, for types like `Option`/`Specified`, from their `onMissingFromObject`). + * + * Example: + * {{{ + * final case class User( + * name: String, + * cachedHash: OmitValue[String] = OmitValue(""), + * ) derives JsonCodec + * }}} + */ +opaque type OmitValue[A] <: A = A +object OmitValue { + + def apply[A](value: A): OmitValue[A] = value + + extension [A](self: OmitValue[A]) def value: A = self + + given encoder: [A: JsonEncoder as enc] => JsonEncoder[OmitValue[A]] = enc.omit + given decoder: [A: JsonDecoder as dec] => JsonDecoder[OmitValue[A]] = dec + +} diff --git a/modules/general/json/src/main/scala/oxygen/json/annotations.scala b/modules/general/json/src/main/scala/oxygen/json/annotations.scala index f754401f..ff64b4f3 100644 --- a/modules/general/json/src/main/scala/oxygen/json/annotations.scala +++ b/modules/general/json/src/main/scala/oxygen/json/annotations.scala @@ -18,3 +18,5 @@ final case class defaultJsonDiscriminator(name: String) extends Annotation deriv final case class jsonStrict() extends Annotation derives ToExprT, FromExprT final case class jsonSecret() extends Annotation derives ToExprT, FromExprT + +final case class jsonOmit() extends Annotation derives ToExprT, FromExprT diff --git a/modules/general/json/src/main/scala/oxygen/json/generic/DeriveProductJsonDecoder.scala b/modules/general/json/src/main/scala/oxygen/json/generic/DeriveProductJsonDecoder.scala index 00c976c0..286f97e6 100644 --- a/modules/general/json/src/main/scala/oxygen/json/generic/DeriveProductJsonDecoder.scala +++ b/modules/general/json/src/main/scala/oxygen/json/generic/DeriveProductJsonDecoder.scala @@ -15,8 +15,12 @@ final class DeriveProductJsonDecoder[A]( val tmp1: Growable[Expr[Iterable[String]]] = generic.mapChildren.mapExpr[Iterable[String]] { [a] => (_, _) ?=> (field: generic.Field[a]) => val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty + val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty - if isFlattened then + if isOmitted then + // Omitted fields are not part of the wire schema, so they contribute no keys. + '{ Nil } + else if isFlattened then '{ ${ field.getExpr(instances) }.toObjectDecoderOrThrow.keys } @@ -35,8 +39,16 @@ final class DeriveProductJsonDecoder[A]( val fieldNameExpr: Expr[String] = Expr(field.annotations.optionalOfValue[jsonField].fold(field.name)(_.name)) val instanceExpr: Expr[JsonDecoder[a]] = field.getExpr(instances) val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty + val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty - if isFlattened then + if isOmitted then + // `@jsonOmit`: field is never on the wire. Any incoming value is ignored; reconstruct from + // the decoder's `onMissingFromObject` (constructor default, `None`, `WasNotSpecified`, ...). + '{ + $instanceExpr.onMissingFromObject + .toRight(JsonError(JsonError.Path.Field($fieldNameExpr) :: Nil, JsonError.Cause.MissingRequired)) + } + else if isFlattened then // TODO (KR) : is there a more type-safe & compile-time way to do this? '{ $instanceExpr.toObjectDecoderOrThrow.decodeJsonObjectAST($obj, $map) diff --git a/modules/general/json/src/main/scala/oxygen/json/generic/DeriveProductJsonEncoder.scala b/modules/general/json/src/main/scala/oxygen/json/generic/DeriveProductJsonEncoder.scala index 2e1c702e..e1b61e20 100644 --- a/modules/general/json/src/main/scala/oxygen/json/generic/DeriveProductJsonEncoder.scala +++ b/modules/general/json/src/main/scala/oxygen/json/generic/DeriveProductJsonEncoder.scala @@ -19,8 +19,16 @@ final class DeriveProductJsonEncoder[A]( val instanceExpr: Expr[JsonEncoder[a]] = field.getExpr(instances) val fieldExpr: Expr[a] = field.fromParent(value) val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty + val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty - if isFlattened then + if isOmitted then + // `@jsonOmit` unconditionally suppresses the field (takes precedence over `@jsonFlatten`). + // Reference the instance so its cached val is not flagged unused by `-Wunused`. + '{ + val _ = $instanceExpr + Growable.empty[(String, Json)] + } + else if isFlattened then // TODO (KR) : is there a more type-safe & compile-time way to do this? '{ $instanceExpr.toObjectEncoderOrThrow.encodeJsonObjectFields($fieldExpr) @@ -48,8 +56,16 @@ final class DeriveProductJsonEncoder[A]( val instanceExpr: Expr[JsonEncoder[a]] = field.getExpr(instances) val fieldExpr: Expr[a] = field.fromParent(value) val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty + val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty - if isFlattened then + if isOmitted then + // `@jsonOmit` unconditionally suppresses the field (takes precedence over `@jsonFlatten`). + // Reference the instance so its cached val is not flagged unused by `-Wunused`. + '{ + val _ = $instanceExpr + Growable.empty[(String, Ior[PlainTextJson, SecretJson])] + } + else if isFlattened then // TODO (KR) : is there a more type-safe & compile-time way to do this? '{ $instanceExpr.toObjectEncoderOrThrow.encodeSplitJsonObjectFields($fieldExpr) diff --git a/modules/general/json/src/main/scala/oxygen/predef/json.scala b/modules/general/json/src/main/scala/oxygen/predef/json.scala index ec10f209..2a71062b 100644 --- a/modules/general/json/src/main/scala/oxygen/predef/json.scala +++ b/modules/general/json/src/main/scala/oxygen/predef/json.scala @@ -1,8 +1,8 @@ package oxygen.predef object json { - export oxygen.json.{Json, JsonCodec, JsonDecoder, JsonEncoder, JsonError, KeyedMapDecoder} - export oxygen.json.{jsonDiscriminator, jsonField, jsonFlatten, jsonType} + export oxygen.json.{Json, JsonCodec, JsonDecoder, JsonEncoder, JsonError, KeyedMapDecoder, OmitValue} + export oxygen.json.{jsonDiscriminator, jsonField, jsonFlatten, jsonOmit, jsonType} export oxygen.json.instances.given export oxygen.json.syntax.build.* export oxygen.json.syntax.json.* diff --git a/modules/general/schema/src/main/scala/oxygen/schema/JsonSchema.scala b/modules/general/schema/src/main/scala/oxygen/schema/JsonSchema.scala index 59ca1663..8e7d1fc7 100644 --- a/modules/general/schema/src/main/scala/oxygen/schema/JsonSchema.scala +++ b/modules/general/schema/src/main/scala/oxygen/schema/JsonSchema.scala @@ -560,6 +560,7 @@ object JsonSchema extends Derivable[JsonSchema.ObjectLike], JsonSchemaLowPriorit val fieldDoc: Option[String] = field.annotations.optionalOfValue[doc].map(_.value) val instanceExpr: Expr[JsonSchema[a]] = field.getExpr(instances) val isFlattened: Boolean = field.annotations.optionalOf[jsonFlatten].nonEmpty + val isOmitted: Boolean = field.annotations.optionalOf[jsonOmit].nonEmpty def flattenSumErrorString(underlyingType: String): String = s"""Not Supprted : JsonSchema only supports @jsonFlatten on product schema, but got $underlyingType. @@ -568,7 +569,11 @@ object JsonSchema extends Derivable[JsonSchema.ObjectLike], JsonSchemaLowPriorit | field-type: ${field.typeRepr.showCode} |""".stripMargin - if isFlattened then + if isOmitted then + // `@jsonOmit` fields are not part of the wire representation, so hide them from the schema. + // The instance is still referenced via the reused encoder/decoder derivation. + '{ Growable.empty[ProductField[?]] } + else if isFlattened then '{ $instanceExpr.toProductLikeOrThrow match { case fieldInstance: JsonSchema.ProductSchema[?] => Growable.many(fieldInstance.fields) diff --git a/modules/tests/pre-test-unit-tests/src/test/scala/oxygen/json/JsonSpec.scala b/modules/tests/pre-test-unit-tests/src/test/scala/oxygen/json/JsonSpec.scala index 7b266580..d5cf91da 100644 --- a/modules/tests/pre-test-unit-tests/src/test/scala/oxygen/json/JsonSpec.scala +++ b/modules/tests/pre-test-unit-tests/src/test/scala/oxygen/json/JsonSpec.scala @@ -179,6 +179,37 @@ object JsonSpec extends OxygenSpecDefault { field: Json, ) derives JsonCodec + final case class OmitAnnotated( + s: String, + @jsonOmit hidden: String = "default", + ) derives JsonCodec + + final case class OmitAnnotatedOption( + s: String, + @jsonOmit hidden: Option[String], + ) derives JsonCodec + + final case class OmitWrapped( + s: String, + hidden: OmitValue[String] = OmitValue("default"), + ) derives JsonCodec + + final case class OmitWrappedOption( + s: String, + hidden: OmitValue[Option[String]] = OmitValue(None), + ) derives JsonCodec + + final case class OmitFlatten( + outer1: Int, + @jsonOmit @jsonFlatten inner: FlattenInner = FlattenInner(0, None), + ) derives JsonCodec + + @jsonStrict + final case class OmitStrict( + i: Int, + @jsonOmit hidden: String = "default", + ) derives JsonCodec + override def testSpec: TestSpec = suite("JsonSpec")( suite("provided instances")( @@ -289,6 +320,48 @@ object JsonSpec extends OxygenSpecDefault { directRoundTripTest[NonStrict2]("""{"b":true,"i":1}""")(NonStrict2(true, Strict1(1, None))), successfulDecodeTest[NonStrict2]("""{"b":true,"i":1,"s":"here","extra":true}""")(NonStrict2(true, Strict1(1, "here".some))), ), + suite("omit")( + suite("@jsonOmit annotation")( + // encodes without the omitted field; decodes without the key using the default + directRoundTripTest[OmitAnnotated]("""{"s":"a"}""")(OmitAnnotated("a")), + directRoundTripTest[OmitAnnotated]("""{"s":"a"}""")(OmitAnnotated("a", "default")), + // omitted regardless of the field's runtime value + test("omits non-default value") { + assert(JsonEncoder[OmitAnnotated].encodeJsonStringCompact(OmitAnnotated("a", "SECRET")))(equalTo("""{"s":"a"}""")) + }, + // an incoming value for the omitted key is ignored (non-strict) + successfulDecodeTest[OmitAnnotated]("""{"s":"a","hidden":"ignored"}""")(OmitAnnotated("a", "default")), + ), + suite("@jsonOmit with Option (no default)")( + directRoundTripTest[OmitAnnotatedOption]("""{"s":"a"}""")(OmitAnnotatedOption("a", None)), + test("omits present Option value") { + assert(JsonEncoder[OmitAnnotatedOption].encodeJsonStringCompact(OmitAnnotatedOption("a", "x".some)))(equalTo("""{"s":"a"}""")) + }, + ), + suite("OmitValue wrapper")( + directRoundTripTest[OmitWrapped]("""{"s":"a"}""")(OmitWrapped("a")), + test("omits non-default wrapped value") { + assert(JsonEncoder[OmitWrapped].encodeJsonStringCompact(OmitWrapped("a", OmitValue("SECRET"))))(equalTo("""{"s":"a"}""")) + }, + // NOTE: unlike the `@jsonOmit` annotation, the `OmitValue` type only affects encoding. + // On decode there is no annotation to hook, so a present key is decoded normally. + successfulDecodeTest[OmitWrapped]("""{"s":"a","hidden":"ignored"}""")(OmitWrapped("a", OmitValue("ignored"))), + // OmitValue[Option[_]] round-trips without an explicit constructor default value + directRoundTripTest[OmitWrappedOption]("""{"s":"a"}""")(OmitWrappedOption("a")), + ), + suite("@jsonOmit + @jsonFlatten")( + // omit wins over flatten: the flattened object is fully suppressed + directRoundTripTest[OmitFlatten]("""{"outer1":1}""")(OmitFlatten(1)), + test("omits flattened value") { + assert(JsonEncoder[OmitFlatten].encodeJsonStringCompact(OmitFlatten(1, FlattenInner(2, "x".some))))(equalTo("""{"outer1":1}""")) + }, + ), + suite("@jsonOmit + @jsonStrict")( + directRoundTripTest[OmitStrict]("""{"i":1}""")(OmitStrict(1)), + // in strict mode the omitted key is not part of the schema -> rejected as extra + failedDecodeTest[OmitStrict]("""{"i":1,"hidden":"x"}"""), + ), + ), ), suite("string transform")( directRoundTripTest("""{"field":"eyJ0eXBlIjoiYmFzZTY0IiwidmFsdWUiOiJTdHJpbmcifQ"}""")(MyClass2(MyClass1("base64", "String"))), diff --git a/modules/tests/pre-test-unit-tests/src/test/scala/oxygen/schema/JsonSchemaEmitterSpec.scala b/modules/tests/pre-test-unit-tests/src/test/scala/oxygen/schema/JsonSchemaEmitterSpec.scala index 8482183c..b9a56377 100644 --- a/modules/tests/pre-test-unit-tests/src/test/scala/oxygen/schema/JsonSchemaEmitterSpec.scala +++ b/modules/tests/pre-test-unit-tests/src/test/scala/oxygen/schema/JsonSchemaEmitterSpec.scala @@ -29,6 +29,11 @@ object JsonSchemaEmitterSpec extends OxygenSpecDefault { children: List[Tree], ) derives JsonSchema + final case class WithOmit( + name: String, + @jsonOmit internalId: String = "internal", + ) derives JsonSchema + ////////////////////////////////////////////////////////////////////////////////////////////////////// // Helpers ////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -127,6 +132,16 @@ object JsonSchemaEmitterSpec extends OxygenSpecDefault { lead.fld("$ref").asString == members.fld("items").fld("$ref").asString, ) }, + test("@jsonOmit field is hidden from the emitted schema") { + val withOmit = soleDef(standalone[WithOmit]) + val props = withOmit.fld("properties") + val required = stringSet(withOmit.fld("required")) + assertTrue( + props.objKeys == Set("name"), + !props.objKeys.contains("internalId"), + !required.contains("internalId"), + ) + }, test("recursive type terminates via $ref to itself") { val root = standalone[Tree] val treeDef = soleDef(root) diff --git a/report/OXY-147.md b/report/OXY-147.md new file mode 100644 index 00000000..69477c0a --- /dev/null +++ b/report/OXY-147.md @@ -0,0 +1,102 @@ +# OXY-147 — `JsonEncoder.Omit` typeclass + `@jsonOmit` annotation + +Mirror the existing `jsonSecret` / `SecretValue` / `JsonEncoder.Secret` pattern, but for +**unconditional suppression** of a field (never appears on the wire) rather than redaction. + +## Key constraints discovered +- Build uses `-Werror` + `-Wunused:all` (project/Settings.scala). Every cached derivation + instance MUST be referenced or compilation fails. This drove several design choices. +- `oxygen-json` has NO test dir of its own; json tests live in + `modules/tests/pre-test-unit-tests/src/test/scala/oxygen/json/JsonSpec.scala`. +- `SecretValue` is defined but not used anywhere in the repo (encode-only helper). It only + provides a `JsonEncoder` given, no decoder. +- `DeriveProductJsonEncoder` / `DeriveProductJsonDecoder` are re-used by the schema module + (`JsonSchema.scala`), so changes there affect schema too. + +## Design decisions (open questions resolved) +- **Encoder typeclass**: `JsonEncoder.Omit[A]` sealed trait with `addToObject = false`; + `OmitEncoder`/`Contramapped` impls delegate `encodeJsonAST`/`encodeSplitJsonAST` to the + underlying. `def omit` added to `JsonEncoder` trait (parallel to `def secret`). +- **`OmitValue[A]`** opaque `<: A = A` (parallel to `SecretValue`). Provides BOTH an encoder + given (`enc.omit`) and a decoder given (`dec`, transparent inside the opaque scope). + Rationale for adding a decoder given (unlike SecretValue): OmitValue is meant to be usable + as a real field type in a derived `JsonCodec`, so it must decode. `OmitValue[Option[A]]` + round-trips with no explicit default (Option decoder supplies `onMissingFromObject = None`). + A plain `OmitValue[A]` needs a constructor default to round-trip (see decode behavior). +- **`@jsonOmit` annotation** handled in `productDeriver` (wraps instance with `.omit`, alongside + `jsonSecret`'s `.secret`) AND in `DeriveProductJsonEncoder` (emits `Growable.empty`, winning + over `@jsonFlatten`). The encoder still *references* the instance (`val _ = instance`) so the + cached lazy val is not flagged unused by `-Wunused`. +- **Decode behavior for omitted fields** (`@jsonOmit`): + - Missing key (the normal case): use `onMissingFromObject` (which picks up any constructor + default via `.withDefault`, or `None`/`WasNotSpecified` for Option/Specified). If neither + is available -> `MissingRequired` error. Documented; give omitted plain fields a default. + - Key IS present: **ignored**. Omitted fields are excluded from the decoder's `keys` set and + the field map is never consulted for them. Consequence: in `@jsonStrict` mode, an incoming + payload that DOES contain the omitted key is rejected as an extra key (the field is not part + of the wire schema). In the default non-strict mode it is silently ignored. Chosen for full + symmetry with encoding: the field simply is not part of the JSON representation. +- **Schema**: omitted fields are hidden from the emitted `JsonSchema` `fields` list (parallel to + how `jsonSecret` is handled via `.secret`). The instance is still referenced through the + reused encoder/decoder derivation, so no unused-warning. +- **`JsonCodec`**: no new given needed — `OmitValue` composes from the encoder+decoder givens + via the existing `fromEncoderAndDecoder` low-priority given. +- **predef exports**: added `jsonOmit` and `OmitValue` (kept consistent with existing exports). + +## Files changed +- `annotations.scala` — add `jsonOmit`. +- `JsonEncoder.scala` — add `def omit`, `Omit`/`OmitEncoder`/`Contramapped`; handle `@jsonOmit` + in `productDeriver`. +- `OmitValue.scala` — new opaque wrapper w/ encoder + decoder givens. +- `generic/DeriveProductJsonEncoder.scala` — emit empty for `@jsonOmit` in both field paths. +- `generic/DeriveProductJsonDecoder.scala` — skip omitted key in `keys`; use onMissing/default. +- `JsonDecoder.scala` — (no structural change needed; onMissing already exists). +- `predef/json.scala` — export `jsonOmit`, `OmitValue`. +- `schema/JsonSchema.scala` — hide omitted fields from emitted schema. +- `JsonSpec.scala` — unit tests. + +## Important asymmetry (documented) +The two mechanisms differ on decode when the key IS present: +- **`@jsonOmit` annotation**: fully transient. Excluded from the decoder `keys` set; the field map + is never consulted; an incoming value is IGNORED (and rejected as an extra key under `@jsonStrict`). +- **`OmitValue[A]` type**: encode-only suppression. There is no annotation for the decoder + derivation to hook, so a present key is decoded normally (value is kept, not ignored). `OmitValue` + therefore behaves like a normal field on decode, omitted only on encode. `OmitValue[Option[A]]` + round-trips with no explicit default (Option supplies `onMissingFromObject`). + +Reason `OmitValue` can't ignore a present key: the "if key present -> decode" branch lives in +`DeriveProductJsonDecoder` and only sees annotations, not field types; the decoder instance has no +way to override it or invent a default. Making them symmetric would require detecting the `OmitValue` +type in the decoder macro — deemed out of scope. + +`OmitValue` has NO `JsonSchema` given (parity with `SecretValue`), so it cannot be used as a field +of a `derives JsonSchema` type; use `@jsonOmit` there instead (which IS hidden from the schema). + +## Build/test verification +- `oxygen-jsonJVM/compile` — OK +- `oxygen-schemaJVM/compile` — OK +- `utJVM/testOnly oxygen.json.JsonSpec` — 85 passed, 0 failed +- `utJVM/testOnly oxygen.schema.*` — all passed (incl. new omit-hidden-from-schema test) +- NOTE: sbt-git's JGit crashes on a linked git worktree ("Bare Repository has neither a working + tree"). A temporary, non-committed `zz-worktree-git-workaround.sbt` overrode the git keys to + build/test; it was deleted before committing. Building from the main checkout does not need it. + +## FINAL SUMMARY +Implemented both halves of the omit pattern (typeclass/wrapper + annotation), wired through product +encoder/decoder derivation and the schema emitter, with exports and tests. All targeted builds/tests +pass. Mirrors the `jsonSecret`/`SecretValue`/`JsonEncoder.Secret` structure closely. + +Assumptions to flag for review: +- Decode-when-key-present semantics differ between annotation (ignore) and wrapper (decode) — see + asymmetry section. This is the main judgment call. +- Omitted `@jsonOmit` plain fields require a constructor default (or an `onMissing`-providing type + like Option/Specified) to decode; otherwise `MissingRequired`. Documented. +- `@jsonStrict` + `@jsonOmit`: incoming omitted key is rejected as extra (field not in wire schema). + +**CONFIDENCE: 8/10.** Core behavior implemented, mirrors the existing pattern, and is covered by +passing unit + schema tests. Deductions: (1) the encode/decode asymmetry between the annotation and +the `OmitValue` type is a design call a maintainer might want tweaked; (2) no `JsonCodec`/`JsonSchema` +givens were added for `OmitValue` (kept parity with `SecretValue`), which a reviewer may want extended; +(3) could not run the FULL repo test suite (only json + schema modules) due to time/worktree-git. + +