diff --git a/fixtures/conformance/flattened-kitchen-sink/expected-effective.json b/fixtures/conformance/flattened-kitchen-sink/expected-effective.json
index 92e92eb30..8e2a77454 100644
--- a/fixtures/conformance/flattened-kitchen-sink/expected-effective.json
+++ b/fixtures/conformance/flattened-kitchen-sink/expected-effective.json
@@ -265,8 +265,7 @@
{
"field.timestamp": {
"name": "updatedAt",
- "@autoSet": "onUpdate",
- "@dbColumnType": "timestamp_with_tz"
+ "@autoSet": "onUpdate"
}
},
{
diff --git a/fixtures/conformance/flattened-kitchen-sink/expected.json b/fixtures/conformance/flattened-kitchen-sink/expected.json
index 563bcad88..23457f90a 100644
--- a/fixtures/conformance/flattened-kitchen-sink/expected.json
+++ b/fixtures/conformance/flattened-kitchen-sink/expected.json
@@ -230,8 +230,7 @@
{
"field.timestamp": {
"name": "updatedAt",
- "@autoSet": "onUpdate",
- "@dbColumnType": "timestamp_with_tz"
+ "@autoSet": "onUpdate"
}
},
{
diff --git a/fixtures/conformance/flattened-kitchen-sink/input/meta.catalog.json b/fixtures/conformance/flattened-kitchen-sink/input/meta.catalog.json
index 53985de7a..ca417e679 100644
--- a/fixtures/conformance/flattened-kitchen-sink/input/meta.catalog.json
+++ b/fixtures/conformance/flattened-kitchen-sink/input/meta.catalog.json
@@ -164,8 +164,7 @@
{
"field.timestamp": {
"name": "updatedAt",
- "@autoSet": "onUpdate",
- "@dbColumnType": "timestamp_with_tz"
+ "@autoSet": "onUpdate"
}
},
{
diff --git a/fixtures/metamodel-docs/expected/providers.md b/fixtures/metamodel-docs/expected/providers.md
index e382f7135..53c641869 100644
--- a/fixtures/metamodel-docs/expected/providers.md
+++ b/fixtures/metamodel-docs/expected/providers.md
@@ -65,7 +65,7 @@ DB-domain attributes — @column / @db.indexed / @dbColumnType on every field, @
- `field.object`: `@column`, `@db.indexed`, `@dbColumnType`, `@storage`
- `field.string`: `@column`, `@db.indexed`, `@dbColumnType`
- `field.time`: `@autoSet`, `@column`, `@db.indexed`, `@dbColumnType`
-- `field.timestamp`: `@autoSet`, `@column`, `@db.indexed`, `@dbColumnType`
+- `field.timestamp`: `@autoSet`, `@column`, `@db.indexed`, `@dbColumnType`, `@localTime`
- `field.uuid`: `@column`, `@db.indexed`, `@dbColumnType`
- `identity.reference`: `@constraintName`
- `identity.secondary`: `@expr`, `@orders`, `@using`, `@where`
diff --git a/fixtures/metamodel-docs/expected/types/field.md b/fixtures/metamodel-docs/expected/types/field.md
index c05874cd1..ca1d4b310 100644
--- a/fixtures/metamodel-docs/expected/types/field.md
+++ b/fixtures/metamodel-docs/expected/types/field.md
@@ -22,7 +22,7 @@ Abstract base field — the shared root subtype that concrete field subtypes spe
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -54,7 +54,7 @@ True/false flag. Binds to the native boolean type; DB column is BOOLEAN.
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -89,7 +89,7 @@ Stores money as integer minor units (cents). Binds to long; the client formats v
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@currency` | string | no | `USD` | | metaobjects-core-types | ISO 4217 currency code for a currency-subtype field. Storage is integer minor units; defaults to 'USD' when omitted. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -122,7 +122,7 @@ Calendar date (no time-of-day). Binds to the native date/temporal type; DB colum
| `@autoSet` | string | no | | `onCreate`, `onUpdate` | metaobjects-db | Auto-set semantics for timestamp-like fields: 'onCreate' stamps on insert, 'onUpdate' stamps on every write. |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -156,7 +156,7 @@ Precision-exact decimal (use @precision/@scale). Native TS binding is string (lo
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -190,7 +190,7 @@ Double-precision (64-bit) IEEE-754 floating point. Binds to the native double/nu
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -225,7 +225,7 @@ String-backed enumeration constrained to a closed set of member symbols (@values
| `@coerceDefault` | string | no | | | metaobjects-prompt | Fallback enum member used by tolerant extract when a present value cannot be coerced; must be one of the field's @values. |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@enumAlias` | properties | no | | | metaobjects-prompt | Map of alternate/off-vocabulary tokens to canonical enum members; feeds the FR-010 tolerant extract alias-fold. |
| `@enumDoc` | properties | no | | | metaobjects-prompt | Map of enum member to a human-readable description; shown per-member in the FR-010 'guide'-style prompt fragment. |
@@ -260,7 +260,7 @@ Single-precision floating point. Binds to the native double/number type (TS has
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -292,7 +292,7 @@ Single-precision floating point. Binds to the native double/number type (TS has
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -324,7 +324,7 @@ Single-precision floating point. Binds to the native double/number type (TS has
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -358,7 +358,7 @@ An open-keyed map (Record / dict[str,V]) stored in a single jsonb colu
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -394,7 +394,7 @@ A nested structured value (set @objectRef to the target object). Storage is gove
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -428,7 +428,7 @@ Variable-length text. Binds to the native string type; DB column is VARCHAR/TEXT
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -460,7 +460,7 @@ Time-of-day (no calendar date). Binds to the native date/temporal type; DB colum
| `@autoSet` | string | no | | `onCreate`, `onUpdate` | metaobjects-db | Auto-set semantics for timestamp-like fields: 'onCreate' stamps on insert, 'onUpdate' stamps on every write. |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
@@ -493,11 +493,12 @@ Date + time-of-day instant (optionally with timezone). Binds to the native date/
| `@autoSet` | string | no | | `onCreate`, `onUpdate` | metaobjects-db | Auto-set semantics for timestamp-like fields: 'onCreate' stamps on insert, 'onUpdate' stamps on every write. |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
| `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. |
+| `@localTime` | boolean | no | | | metaobjects-db | When true, the timestamp is a naive wall-clock value with no timezone (Postgres `timestamp without time zone`); absent/false (the default) = an absolute instant (`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz escape hatch. |
| `@readOnly` | boolean | no | | | metaobjects-core-types | FR-013: when true, the field is read-only — codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas mark it read-only on input variants. The value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. |
| `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. |
| `@sortable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD sort allowlists. Inherits from @filterable by default; set false to opt out. |
@@ -525,7 +526,7 @@ Logical UUID identity scalar. A bare scalar (no required attrs, no loader value-
| --- | --- | --- | --- | --- | --- | --- |
| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy. |
| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). |
-| `@dbColumnType` | string | no | | `uuid`, `jsonb`, `timestamp_with_tz` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb \| timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). |
+| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. |
| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). |
| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. |
| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). |
diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json
index 6fa5f3dc2..251a95871 100644
--- a/fixtures/persistence-conformance/canonical/meta.fitness.json
+++ b/fixtures/persistence-conformance/canonical/meta.fitness.json
@@ -10,7 +10,7 @@
{ "field.string": { "name": "title", "@required": true, "@maxLength": 200 } },
{ "field.currency": { "name": "priceCents", "@required": true, "@currency": "USD" } },
{ "field.enum": { "name": "status", "@required": true, "@values": ["DRAFT", "PUBLISHED", "ARCHIVED"] } },
- { "field.timestamp": { "name": "createdAt", "@required": true } },
+ { "field.timestamp": { "name": "createdAt", "@required": true, "@localTime": true } },
{ "relationship.composition": { "name": "weeks", "@objectRef": "Week", "@cardinality": "many" } },
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } },
{ "identity.secondary": { "name": "byTitle", "@fields": "title", "@unique": true } }
@@ -58,8 +58,8 @@
{ "field.uuid": { "name": "ownerId", "@required": true } },
{ "field.string": { "name": "externalId", "@dbColumnType": "uuid" } },
{ "field.string": { "name": "payload", "@dbColumnType": "jsonb" } },
- { "field.timestamp": { "name": "recordedAt", "@required": true, "@dbColumnType": "timestamp_with_tz" } },
- { "field.timestamp": { "name": "observedAt", "@required": true } },
+ { "field.timestamp": { "name": "recordedAt", "@required": true } },
+ { "field.timestamp": { "name": "observedAt", "@required": true, "@localTime": true } },
{ "field.date": { "name": "asOfDate", "@required": true } },
{ "field.time": { "name": "atTime", "@required": true } },
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } }
@@ -229,8 +229,8 @@
{ "field.boolean": { "name": "bVal", "@required": true } },
{ "field.date": { "name": "dateVal", "@required": true } },
{ "field.time": { "name": "timeVal", "@required": true } },
- { "field.timestamp": { "name": "tsVal", "@required": true } },
- { "field.timestamp": { "name": "tsTzVal", "@required": true, "@dbColumnType": "timestamp_with_tz" } },
+ { "field.timestamp": { "name": "tsVal", "@required": true, "@localTime": true } },
+ { "field.timestamp": { "name": "tsTzVal", "@required": true } },
{ "field.currency": { "name": "moneyVal", "@required": true, "@currency": "USD" } },
{ "field.enum": { "name": "enumVal", "@required": true, "@values": ["LOW", "MEDIUM", "HIGH"] } },
{ "field.uuid": { "name": "uuidVal", "@required": true } },
diff --git a/fixtures/persistence-conformance/migrations/asset-native-column-types.yaml b/fixtures/persistence-conformance/migrations/asset-native-column-types.yaml
index 29088c818..ee7319864 100644
--- a/fixtures/persistence-conformance/migrations/asset-native-column-types.yaml
+++ b/fixtures/persistence-conformance/migrations/asset-native-column-types.yaml
@@ -8,8 +8,8 @@ description: |
ownerId uuid (field.uuid, non-key)
externalId uuid (field.string + @dbColumnType: uuid)
payload jsonb (field.string + @dbColumnType: jsonb, genuinely-open JSON)
- recordedAt timestamp with time zone (field.timestamp + @dbColumnType: timestamp_with_tz)
- observedAt timestamp without time zone (field.timestamp → TIMESTAMP)
+ recordedAt timestamp with time zone (field.timestamp → TIMESTAMPTZ, tz-aware by default)
+ observedAt timestamp without time zone (field.timestamp + @localTime: true → TIMESTAMP)
asOfDate date (field.date → DATE)
atTime time without time zone (field.time → TIME)
diff --git a/fixtures/persistence-conformance/queries/asset-uuid-roundtrip.yaml b/fixtures/persistence-conformance/queries/asset-uuid-roundtrip.yaml
index 25b7ae820..8ab39ec71 100644
--- a/fixtures/persistence-conformance/queries/asset-uuid-roundtrip.yaml
+++ b/fixtures/persistence-conformance/queries/asset-uuid-roundtrip.yaml
@@ -13,9 +13,10 @@ description: |
payload is a @dbColumnType:jsonb open-JSON column; jsonb re-serializes with
sorted keys per the normalization contract.
- recordedAt is a @dbColumnType:timestamp_with_tz column (TIMESTAMPTZ). Its
- native physical type (`timestamp with time zone`) is asserted port-
- independently in migrations/asset-native-column-types.yaml. Here it is seeded
+ recordedAt is a plain field.timestamp column — instant / tz-aware BY DEFAULT
+ (ADR-0036 Wave 2), so it is TIMESTAMPTZ. Its native physical type (`timestamp
+ with time zone`) is asserted port-independently in
+ migrations/asset-native-column-types.yaml. Here it is seeded
at exact-UTC instants and asserted in the canonical UTC Z form per
normalization.md. observedAt/asOfDate/atTime are the @required temporal
columns on Asset (full wire-type coverage lives in normalization-wire-types.yaml).
diff --git a/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml b/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml
index 6c53f1bbc..f009d37ed 100644
--- a/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml
+++ b/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml
@@ -29,7 +29,8 @@ description: |
- id (uuid PK): NOT supplied on insert; gen_random_uuid() fills it, the
runtime returns it, and the read-back is by that generated PK — so a
server-generated PK is exercised on the write path too.
- - temporal: tsVal (TIMESTAMP, no Z) + tsTzVal (TIMESTAMPTZ, always Z) +
+ - temporal: tsVal (field.timestamp @localTime → TIMESTAMP, no Z) + tsTzVal
+ (plain field.timestamp → TIMESTAMPTZ, tz-aware by default, always Z) +
timeVal (TIME) carry millisecond components; the whole-second omit-the-dot
rule is proven by the second query (a row with no sub-second part).
- float/double: in-band dyadic non-integers (1.5 / 0.125) so every port
diff --git a/fixtures/registry-conformance/coverage-report.json b/fixtures/registry-conformance/coverage-report.json
index 40f50617a..45f6af3b6 100644
--- a/fixtures/registry-conformance/coverage-report.json
+++ b/fixtures/registry-conformance/coverage-report.json
@@ -200,6 +200,7 @@
"untestedAttrs": [
"column",
"db.indexed",
+ "dbColumnType",
"default",
"example",
"instruction",
diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json
index 6c88ecf0f..7b639ddc2 100644
--- a/fixtures/registry-conformance/expected-registry.json
+++ b/fixtures/registry-conformance/expected-registry.json
@@ -89,10 +89,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -220,10 +219,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -359,10 +357,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -501,10 +498,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -633,10 +629,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -778,10 +773,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -917,10 +911,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -1087,10 +1080,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -1218,10 +1210,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -1349,10 +1340,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -1481,10 +1471,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -1627,10 +1616,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -1777,10 +1765,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -1925,10 +1912,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -2067,10 +2053,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
@@ -2100,6 +2085,13 @@
"required": false,
"description": "FR-010: a short instruction for this field, shown in the generated output-format prompt fragment."
},
+ {
+ "name": "localTime",
+ "valueType": "boolean",
+ "isArray": false,
+ "required": false,
+ "description": "When true, the timestamp is a naive wall-clock value with no timezone (Postgres `timestamp without time zone`); absent/false (the default) = an absolute instant (`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz escape hatch."
+ },
{
"name": "readOnly",
"valueType": "boolean",
@@ -2198,10 +2190,9 @@
"required": false,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
},
{
"name": "default",
diff --git a/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs
index 899d2c654..4155060de 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/EntityGeneratorTests.cs
@@ -57,7 +57,8 @@ public void Entity_class_has_table_key_columns_and_nullability()
Assert.Contains("public string Email { get; set; } = default!;", src);
// optional value types -> nullable
Assert.Contains("public bool? Subscribed { get; set; }", src);
- Assert.Contains("public DateTime? CreatedAt { get; set; }", src);
+ // ADR-0036 Wave 2 — default field.timestamp is an absolute instant → DateTimeOffset.
+ Assert.Contains("public DateTimeOffset? CreatedAt { get; set; }", src);
}
[Fact]
diff --git a/server/csharp/MetaObjects.Codegen.Tests/Fr013CodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/Fr013CodegenTests.cs
index 3d6958097..1fbd79510 100644
--- a/server/csharp/MetaObjects.Codegen.Tests/Fr013CodegenTests.cs
+++ b/server/csharp/MetaObjects.Codegen.Tests/Fr013CodegenTests.cs
@@ -72,12 +72,13 @@ public void Entity_readOnly_scalar_emits_private_set()
var root = Load();
var doc = FileContent(new EntityGenerator().Generate(Ctx(root)), "Doc.g.cs");
- // The read-only field gets a private setter.
- Assert.Contains("public DateTime? CreatedAt { get; private set; }", doc);
+ // The read-only field gets a private setter. (ADR-0036 Wave 2: default
+ // field.timestamp is an absolute instant → DateTimeOffset.)
+ Assert.Contains("public DateTimeOffset? CreatedAt { get; private set; }", doc);
// A normal field keeps its public setter.
Assert.Contains("public string Name { get; set; }", doc);
// And the read-only field is NOT emitted with a public set.
- Assert.DoesNotContain("public DateTime? CreatedAt { get; set; }", doc);
+ Assert.DoesNotContain("public DateTimeOffset? CreatedAt { get; set; }", doc);
}
[Fact]
diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs
index 70b65ade7..e660b8bc2 100644
--- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs
+++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs
@@ -45,7 +45,11 @@ public static class CSharpNaming
[FIELD_SUBTYPE_BOOLEAN] = "bool",
[FIELD_SUBTYPE_DATE] = "DateOnly",
[FIELD_SUBTYPE_TIME] = "TimeOnly",
- [FIELD_SUBTYPE_TIMESTAMP] = "DateTime",
+ // ADR-0036 Wave 2 — field.timestamp's CLR type is CONDITIONAL on @localTime
+ // (see ScalarForField): default = DateTimeOffset (an absolute instant), and
+ // @localTime:true = DateTime (a naive wall-clock value). This subtype-keyed
+ // entry is the DEFAULT (no field in hand); the per-field overload overrides it.
+ [FIELD_SUBTYPE_TIMESTAMP] = "DateTimeOffset",
// R6 Plan 2a — field.uuid binds to the native System.Guid value type
// (ADR-0001), independent of its string wire form. The native uuid DB
// column is a separate migrate-engine concern (SubtypeToSqlType).
@@ -54,12 +58,33 @@ public static class CSharpNaming
/// Value types that take a ? suffix when nullable (vs. reference types).
private static readonly HashSet ValueTypes = new(StringComparer.Ordinal)
- { "int", "long", "double", "float", "decimal", "bool", "DateOnly", "TimeOnly", "DateTime", "Guid" };
+ { "int", "long", "double", "float", "decimal", "bool", "DateOnly", "TimeOnly", "DateTime", "DateTimeOffset", "Guid" };
/// The base C# scalar type for a field subtype (no nullability), or null for object fields.
public static string? ScalarFor(string fieldSubType) =>
ScalarType.GetValueOrDefault(fieldSubType);
+ ///
+ /// ADR-0036 Wave 2 — true when a field.timestamp opts OUT of instant
+ /// semantics via @localTime: true (a naive wall-clock value). Own-only,
+ /// matching the loader's physical-attr policy. Always false for non-timestamp fields.
+ ///
+ public static bool IsLocalTime(MetaField field) =>
+ field.SubType == FIELD_SUBTYPE_TIMESTAMP && field.OwnAttr(DbConstants.FIELD_ATTR_LOCAL_TIME) is true;
+
+ ///
+ /// The base C# scalar type for a field (no nullability), accounting for the
+ /// ADR-0036 Wave 2 conditional field.timestamp binding:
+ ///
+ /// - default field.timestamp → DateTimeOffset (an absolute instant / timestamptz);
+ /// - field.timestamp @localTime:true → DateTime (a naive wall-clock value / timestamp).
+ ///
+ /// Every other subtype delegates to the subtype-keyed .
+ /// Returns null for object fields.
+ ///
+ public static string? ScalarForField(MetaField field) =>
+ IsLocalTime(field) ? "DateTime" : ScalarFor(field.SubType);
+
///
/// The System.Text.Json type that holds a parsed JSON value — the CLR property
/// type for a @dbColumnType:jsonb open-JSON bag. JsonDocument is
diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
index 8494a7278..164787d78 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs
@@ -107,24 +107,29 @@ public virtual IEnumerable Generate(GenContext ctx)
? $" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasPrecision({f.Precision}, {sc});"
: $" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasPrecision({f.Precision});");
}
- // field.timestamp maps to a plain `timestamp without time zone` column
- // (the cross-port contract: @dbColumnType:timestamp_with_tz opts INTO
- // timestamptz). Npgsql's DbDateTime provider default is timestamptz, which
- // rejects a Kind=Unspecified DateTime on WRITE — so without this explicit
- // mapping a create/update over a `field.timestamp` column throws at runtime
- // (DbUpdateException: "Cannot write DateTime with Kind=Unspecified to
- // PostgreSQL type 'timestamp with time zone'"). The @dbColumnType path below
- // owns the timestamptz opt-in, so only emit this for plain timestamps.
- foreach (var f in e.Fields().Where(f =>
- !f.IsArray && f.SubType == FIELD_SUBTYPE_TIMESTAMP && f.DbColumnType is null))
+ // ADR-0036 Wave 2 — field.timestamp column type follows @localTime:
+ // • default (no @localTime) → an absolute instant: CLR DateTimeOffset over a
+ // `timestamp with time zone` (timestamptz) column. Npgsql maps DateTimeOffset
+ // to timestamptz natively; the explicit HasColumnType keeps the model and the
+ // TS-owned schema DDL in lockstep.
+ // • @localTime:true → a naive wall-clock value: CLR DateTime (Kind=Unspecified)
+ // over a `timestamp without time zone` column. Npgsql's provider default is
+ // timestamptz, which rejects a Kind=Unspecified DateTime on WRITE, so this
+ // explicit mapping is REQUIRED (else DbUpdateException at runtime: "Cannot
+ // write DateTime with Kind=Unspecified to PostgreSQL type 'timestamp with
+ // time zone'").
+ foreach (var f in e.Fields().Where(f => !f.IsArray && f.SubType == FIELD_SUBTYPE_TIMESTAMP))
{
var prop = CSharpNaming.Pascal(f.Name);
- modelLines.Add($" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasColumnType(\"timestamp without time zone\");");
+ var colType = CSharpNaming.IsLocalTime(f)
+ ? "timestamp without time zone"
+ : "timestamp with time zone";
+ modelLines.Add($" modelBuilder.Entity<{owner}>().Property(x => x.{prop}).HasColumnType(\"{colType}\");");
}
// R6 Plan 2b — @dbColumnType physical overrides. The logical field stays its
// native CLR type (a @dbColumnType:uuid string is still C# string); EF must be
// told the provider column type (and, for uuid, a string↔Guid value converter)
- // so the native uuid / jsonb / timestamptz column round-trips into that property.
+ // so the native uuid / jsonb column round-trips into that property.
foreach (var f in e.Fields().Where(f => !f.IsArray && f.DbColumnType is not null))
if (DbColumnTypeConfig(owner, f) is { } cfg) modelLines.Add(cfg);
@@ -259,7 +264,8 @@ protected virtual void EmitOnModelCreatingBody(StringBuilder sb, IReadOnlyList Guid.Parse(v!), g => g.ToString(\"D\"));",
DbConstants.DB_COLUMN_TYPE_JSONB =>
lhs + ".HasColumnType(\"jsonb\");",
- DbConstants.DB_COLUMN_TYPE_TIMESTAMP_TZ =>
- lhs + ".HasColumnType(\"timestamp with time zone\");",
_ => null,
};
}
diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs
index 8b5ebe71c..f66e1f25b 100644
--- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs
+++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs
@@ -391,7 +391,8 @@ protected virtual EmittedFile EmitTphSubtypeClass(MetaObject entity, GenContext
// interfaces declare these members non-null. Arrays keep the List shape.
protected virtual string TphSubtypeScalarProperty(MetaObject entity, MetaField field, ColumnNamingStrategy strategy)
{
- var baseType = CSharpNaming.ScalarFor(field.SubType)!;
+ // ScalarForField resolves the ADR-0036 Wave 2 conditional field.timestamp binding.
+ var baseType = CSharpNaming.ScalarForField(field)!;
var propName = PropertyName(field);
if (field.IsArray)
@@ -742,10 +743,12 @@ protected virtual string ScalarProperty(
string? baseTypeOverride = null)
{
// A @dbColumnType:jsonb open-bag shifts the CLR property to a parsed JSON value
- // (JsonDocument) — issue #98 — taking precedence over the logical scalar. uuid/
- // timestamp_with_tz overrides keep the logical CLR type (converted at the DB seam).
+ // (JsonDocument) — issue #98 — taking precedence over the logical scalar. A uuid
+ // override keeps the logical CLR type (converted at the DB seam). ScalarForField
+ // resolves the ADR-0036 Wave 2 conditional field.timestamp binding (default
+ // DateTimeOffset / @localTime:true DateTime).
var baseType = CSharpNaming.DbColumnTypeClrOverride(field)
- ?? baseTypeOverride ?? CSharpNaming.ScalarFor(field.SubType)!;
+ ?? baseTypeOverride ?? CSharpNaming.ScalarForField(field)!;
var propName = PropertyName(field);
// Array fields: emit List with an empty-list initializer and only a [Column]
@@ -968,7 +971,7 @@ protected string MapProperty(MetaObject owner, MetaField field, GenContext ctx,
if (dot <= 0 || dot == of.Length - 1) return null;
var sourceObj = ctx.Root.FindObject(CSharpNaming.StripPkg(of[..dot]));
var sourceField = sourceObj?.FindField(of[(dot + 1)..]);
- return sourceField is null ? null : CSharpNaming.ScalarFor(sourceField.SubType);
+ return sourceField is null ? null : CSharpNaming.ScalarForField(sourceField);
}
// True for the fields that reference a value-object by @objectRef and so contribute
diff --git a/server/csharp/MetaObjects.Conformance.Tests/R6Plan2LoaderTests.cs b/server/csharp/MetaObjects.Conformance.Tests/R6Plan2LoaderTests.cs
index 0d8077ec7..6f5f955dc 100644
--- a/server/csharp/MetaObjects.Conformance.Tests/R6Plan2LoaderTests.cs
+++ b/server/csharp/MetaObjects.Conformance.Tests/R6Plan2LoaderTests.cs
@@ -84,7 +84,8 @@ private static string Pairing(string sub, string dbt) =>
[Theory]
[InlineData(FIELD_SUBTYPE_STRING, DB_COLUMN_TYPE_UUID)]
[InlineData(FIELD_SUBTYPE_STRING, DB_COLUMN_TYPE_JSONB)]
- [InlineData(FIELD_SUBTYPE_TIMESTAMP, DB_COLUMN_TYPE_TIMESTAMP_TZ)]
+ // ADR-0036 Wave 2 — timestamp_with_tz retired; the only legal pairings are uuid/jsonb
+ // on field.string (timezone-awareness now lives in field.timestamp + @localTime).
public void DbColumnType_legal_pairing_loads_clean(string fieldSubtype, string dbColumnType)
{
var res = LoadJson(Pairing(fieldSubtype, dbColumnType));
@@ -97,8 +98,10 @@ public void DbColumnType_legal_pairing_loads_clean(string fieldSubtype, string d
// ------------------------------------------------------------------------
[Theory]
- // timestamp_with_tz illegal on field.string (requires field.timestamp).
- [InlineData(FIELD_SUBTYPE_STRING, DB_COLUMN_TYPE_TIMESTAMP_TZ)]
+ // ADR-0036 Wave 2 — the retired timestamp_with_tz value is now an UNKNOWN value
+ // (Rule 1), still ERR_BAD_ATTR_VALUE with the value named in the message.
+ [InlineData(FIELD_SUBTYPE_TIMESTAMP, "timestamp_with_tz")]
+ [InlineData(FIELD_SUBTYPE_STRING, "timestamp_with_tz")]
// uuid illegal on field.timestamp (requires field.string).
[InlineData(FIELD_SUBTYPE_TIMESTAMP, DB_COLUMN_TYPE_UUID)]
// jsonb illegal on field.long (requires field.string).
diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs
index 373e0f063..a8fa9bbea 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs
@@ -82,7 +82,10 @@ public static async Task StartAsync(PostgresContai
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
bio VARCHAR(1000),
- ""createdAt"" TIMESTAMP NOT NULL
+ -- ADR-0036 Wave 2: a default field.timestamp is an absolute instant →
+ -- the generated EF model binds CreatedAt to DateTimeOffset, which Npgsql
+ -- reads from a `timestamp with time zone` (timestamptz) column.
+ ""createdAt"" TIMESTAMPTZ NOT NULL
)";
await cmd.ExecuteNonQueryAsync();
}
@@ -101,6 +104,15 @@ bio VARCHAR(1000),
// Quiet the host (no console spew during the test run).
builder.Logging.ClearProviders();
+ // ADR-0036 Wave 2 — a default field.timestamp binds to DateTimeOffset (timestamptz).
+ // The api-contract corpus authors an instant body field with NO offset
+ // ("2026-02-01T10:00:00"); System.Text.Json would attach the HOST machine's local
+ // offset, and Npgsql rejects a non-UTC DateTimeOffset on a timestamptz write. Interpret
+ // an offset-less instant as UTC (the cross-port wire contract for an instant field), so
+ // the generated EF write succeeds regardless of the CI host's timezone.
+ builder.Services.Configure(o =>
+ o.SerializerOptions.Converters.Add(new UtcDateTimeOffsetConverter()));
+
// Register the GENERATED AppDbContext, pointed at the Testcontainers PG.
// AddDbContext needs the closed generic type at runtime, so
// dispatch reflectively to the compiled DbContext type.
@@ -289,9 +301,14 @@ public async Task ApplySeedAsync(IReadOnlyList> rows
// ----- helpers -----
+ // ADR-0036 Wave 2 — the seed authors' createdAt instant is bound to the timestamptz
+ // column as a UTC DateTime (Kind=Utc), the Npgsql write form for `timestamp with time
+ // zone`. The naive seed strings (no offset) are interpreted as UTC instants.
private static DateTime ParseTimestamp(string s) =>
- DateTime.ParseExact(s, TimestampFmt, System.Globalization.CultureInfo.InvariantCulture,
- System.Globalization.DateTimeStyles.None);
+ DateTime.SpecifyKind(
+ DateTime.ParseExact(s, TimestampFmt, System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None),
+ DateTimeKind.Utc);
private static int PickFreePort()
{
@@ -302,3 +319,26 @@ private static int PickFreePort()
return port;
}
}
+
+///
+/// ADR-0036 Wave 2 — deserializes an instant body field to a UTC .
+/// A no-offset ISO string ("2026-02-01T10:00:00") is read as UTC (the cross-port instant wire
+/// contract) rather than picking up the host machine's local offset, so the generated EF write
+/// onto a timestamptz column succeeds on any CI host. An explicit offset is honored then
+/// converted to UTC. Serialization stays the System.Text.Json default (round-trip "o" form).
+///
+internal sealed class UtcDateTimeOffsetConverter : System.Text.Json.Serialization.JsonConverter
+{
+ public override DateTimeOffset Read(
+ ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options)
+ {
+ string? raw = reader.GetString();
+ var dto = DateTimeOffset.Parse(raw!, System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal);
+ return dto.ToUniversalTime();
+ }
+
+ public override void Write(
+ System.Text.Json.Utf8JsonWriter writer, DateTimeOffset value, System.Text.Json.JsonSerializerOptions options) =>
+ writer.WriteStringValue(value.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture));
+}
diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypes.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypes.g.cs
index e311aeb3c..b0041ed61 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypes.g.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AllTypes.g.cs
@@ -38,7 +38,7 @@ public enum AllTypesEnumVal { LOW, MEDIUM, HIGH }
[Column("tsVal")]
public DateTime TsVal { get; set; }
[Column("tsTzVal")]
- public DateTime TsTzVal { get; set; }
+ public DateTimeOffset TsTzVal { get; set; }
[Column("moneyVal")]
public long MoneyVal { get; set; }
[Column("enumVal")]
diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs
index 413e8ddda..63e231eba 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Generated/AppDbContext.g.cs
@@ -35,10 +35,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity().Property(x => x.DecVal).HasPrecision(18, 6);
modelBuilder.Entity().Property(x => x.TsVal).HasColumnType("timestamp without time zone");
modelBuilder.Entity().Property(x => x.TsTzVal).HasColumnType("timestamp with time zone");
+ modelBuilder.Entity().Property(x => x.RecordedAt).HasColumnType("timestamp with time zone");
modelBuilder.Entity().Property(x => x.ObservedAt).HasColumnType("timestamp without time zone");
modelBuilder.Entity().Property(x => x.ExternalId).HasColumnType("uuid").HasConversion(v => Guid.Parse(v!), g => g.ToString("D"));
modelBuilder.Entity().Property(x => x.Payload).HasColumnType("jsonb");
- modelBuilder.Entity().Property(x => x.RecordedAt).HasColumnType("timestamp with time zone");
modelBuilder.Entity().Property(x => x.Type).HasConversion();
modelBuilder.Entity().HasDiscriminator(e => e.Type).HasValue(Auth.AuthType.Bridge).HasValue(Auth.AuthType.Copay).HasValue(Auth.AuthType.PriorAuth);
modelBuilder.Entity().Property(x => x.PreciseKg).HasPrecision(9, 4);
diff --git a/server/csharp/MetaObjects.IntegrationTests/Generated/Asset.g.cs b/server/csharp/MetaObjects.IntegrationTests/Generated/Asset.g.cs
index 942991772..beb567762 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Generated/Asset.g.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Generated/Asset.g.cs
@@ -22,7 +22,7 @@ public class Asset
[Column("payload")]
public JsonDocument? Payload { get; set; }
[Column("recordedAt")]
- public DateTime RecordedAt { get; set; }
+ public DateTimeOffset RecordedAt { get; set; }
[Column("observedAt")]
public DateTime ObservedAt { get; set; }
[Column("asOfDate")]
diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/EntityRow.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/EntityRow.cs
index bc7e04b18..dd85bf2c1 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Runner/EntityRow.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Runner/EntityRow.cs
@@ -40,7 +40,10 @@ public static class EntityRow
{
if (value is null) return null;
var t = value.GetType();
- if (t.IsPrimitive || value is string or decimal or DateTime or DateOnly or TimeOnly or Guid or Enum)
+ // ADR-0036 Wave 2 — a default field.timestamp materializes as DateTimeOffset (an
+ // absolute instant); pass it straight through to Normalization (which renders the
+ // UTC wire string + "Z"), NOT into PocoToJsonNode's reflection path.
+ if (t.IsPrimitive || value is string or decimal or DateTime or DateTimeOffset or DateOnly or TimeOnly or Guid or Enum)
return value;
// A @dbColumnType:jsonb open-bag surfaces as JsonDocument/JsonElement (issue #98) —
// pass it through so Normalization re-serializes the parsed value with sorted keys,
diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/Normalization.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/Normalization.cs
index 57b40b086..87c8b7916 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Runner/Normalization.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Runner/Normalization.cs
@@ -63,8 +63,11 @@ string js when TryParseJsonContainer(js, out var node) => NormalizeJsonContainer
DateOnly date => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
TimeOnly time => FormatTimeOnly(time),
DateTime dt => FormatDateTime(dt),
- DateTimeOffset dto => dto.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture)
- .TrimEnd('0').TrimEnd('.') + "Z",
+ // ADR-0036 Wave 2 — a default field.timestamp materializes as DateTimeOffset (an
+ // absolute instant). Normalize through the SAME UTC formatter as a Kind=Utc DateTime
+ // (ms-truncation + trailing-zero trim + single trailing "Z"), so a timestamptz reads
+ // back byte-identical to the other ports' canonicalTimestamptz wire form.
+ DateTimeOffset dto => FormatDateTime(dto.UtcDateTime),
Guid uuid => uuid.ToString("D").ToLowerInvariant(),
byte[] bytes => Convert.ToBase64String(bytes),
// A pre-parsed JsonNode (assembled by a runner path) — sort keys.
diff --git a/server/csharp/MetaObjects.IntegrationTests/Runner/WriteCoercion.cs b/server/csharp/MetaObjects.IntegrationTests/Runner/WriteCoercion.cs
index 1bc5dd30f..328dc8e79 100644
--- a/server/csharp/MetaObjects.IntegrationTests/Runner/WriteCoercion.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/Runner/WriteCoercion.cs
@@ -68,6 +68,10 @@ public static PropertyInfo Property(Type entity, string fieldName) =>
if (underlying == typeof(DateOnly)) return DateOnly.ParseExact(raw, "yyyy-MM-dd", CultureInfo.InvariantCulture);
if (underlying == typeof(TimeOnly)) return ParseTime(raw);
if (underlying == typeof(DateTime)) return ParseDateTime(raw);
+ // ADR-0036 Wave 2 — a default field.timestamp binds to DateTimeOffset (an absolute
+ // instant over timestamptz). Parse the authoring instant to an offset-aware value;
+ // a trailing "Z" yields a UTC (+00:00) DateTimeOffset, the timestamptz write form.
+ if (underlying == typeof(DateTimeOffset)) return ParseDateTimeOffset(raw);
throw new InvalidOperationException(
$"no write coercion for '{prop.Name}' of CLR type {underlying.Name}");
@@ -100,6 +104,16 @@ private static DateTime ParseDateTime(string raw)
return DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
}
+ // ADR-0036 Wave 2 — a default field.timestamp instant → DateTimeOffset (timestamptz).
+ // A trailing "Z" (or any explicit offset) parses to the matching offset; convert to
+ // UTC so the write is offset-canonical (Npgsql maps a UTC DateTimeOffset to timestamptz).
+ private static DateTimeOffset ParseDateTimeOffset(string raw)
+ {
+ var dto = DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
+ return dto.ToUniversalTime();
+ }
+
// Build an owned value-object POCO from a nested mapping, coercing each member to
// its property type (recursively). EF serializes this to the jsonb column.
private static object BuildPoco(YamlMappingNode map, Type pocoType)
diff --git a/server/csharp/MetaObjects.IntegrationTests/RuntimeReturnTypeTests.cs b/server/csharp/MetaObjects.IntegrationTests/RuntimeReturnTypeTests.cs
index 0a2624dad..2ffa7c779 100644
--- a/server/csharp/MetaObjects.IntegrationTests/RuntimeReturnTypeTests.cs
+++ b/server/csharp/MetaObjects.IntegrationTests/RuntimeReturnTypeTests.cs
@@ -82,9 +82,12 @@ public async Task Runtime_returns_native_types_not_wire_strings()
var assetId = Guid.Parse("11111111-1111-4111-8111-111111111111");
var asset = await db.Assets.AsNoTracking().SingleAsync(a => a.Id == assetId);
+ // ADR-0036 Wave 2 — a default field.timestamp is an absolute instant: a native
+ // DateTimeOffset over a `timestamp with time zone` (timestamptz) column, NOT a string.
+ // (A naive field.timestamp @localTime:true would surface as a native DateTime.)
object recordedAt = asset.RecordedAt;
- Assert.True(recordedAt is DateTime,
- $"field.timestamp Asset.RecordedAt (TIMESTAMPTZ) must be a native DateTime, NOT a string. Got: {recordedAt.GetType()}");
+ Assert.True(recordedAt is DateTimeOffset,
+ $"field.timestamp Asset.RecordedAt (TIMESTAMPTZ) must be a native DateTimeOffset, NOT a string. Got: {recordedAt.GetType()}");
Assert.IsNotType(recordedAt);
// jsonb: the generated entity models the open-JSON column as a JsonDocument
diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs
index d4cba2519..3e98f5f5a 100644
--- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs
+++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs
@@ -1348,7 +1348,15 @@ private static void ValidateAttrSchemaNode(
}
// Check 3: allowedValues membership.
- if (spec.AllowedValues is { Count: > 0 } allowed)
+ //
+ // @dbColumnType is exempt here: it carries `allowedValues` ONLY so the
+ // value-set surfaces in the registry manifest (ADR-0036 Wave 1), but its
+ // real constraint — both an unrecognized value AND the (subtype × value)
+ // pairing — is enforced by ValidateDbColumnType, which emits the single
+ // ERR_BAD_ATTR_VALUE. Running the flat membership check too would
+ // double-report. Mirrors the TS Check-3 exemption.
+ if (attrName != FIELD_ATTR_DB_COLUMN_TYPE
+ && spec.AllowedValues is { Count: > 0 } allowed)
{
if (!allowed.Any(av => Equals(av, value)))
{
@@ -1815,12 +1823,11 @@ private static bool ParsesAsFiniteNumber(string s) =>
// Own-only validation of the @dbColumnType physical column-type attribute,
// mirroring the field.enum @values precedent. Two rules:
//
- // 1. The value must be one of the closed set uuid|jsonb|timestamp_with_tz
- // → ERR_BAD_ATTR_VALUE otherwise.
+ // 1. The value must be one of the closed set uuid|jsonb (ADR-0036 Wave 2:
+ // timestamp_with_tz retired) → ERR_BAD_ATTR_VALUE otherwise.
// 2. The (logical subtype × value) pairing must be legal:
- // uuid → field.string
- // jsonb → field.string
- // timestamp_with_tz → field.timestamp
+ // uuid → field.string
+ // jsonb → field.string
// → ERR_BAD_ATTR_VALUE on an illegal pairing.
//
// The error message names the field, the value, and the legal set — matching
@@ -1855,7 +1862,6 @@ private static void WalkDbColumnType(MetaData node, List errors)
var requiredSubType = value switch
{
DB_COLUMN_TYPE_UUID or DB_COLUMN_TYPE_JSONB => FIELD_SUBTYPE_STRING,
- DB_COLUMN_TYPE_TIMESTAMP_TZ => FIELD_SUBTYPE_TIMESTAMP,
_ => null, // unreachable (Rule 1)
};
if (requiredSubType is not null && field.SubType != requiredSubType)
@@ -1864,8 +1870,7 @@ private static void WalkDbColumnType(MetaData node, List errors)
$"field '{field.Name}' @{FIELD_ATTR_DB_COLUMN_TYPE} '{value}' is not valid on " +
$"field.{field.SubType} (requires field.{requiredSubType}); allowed pairings: " +
$"{DB_COLUMN_TYPE_UUID}→field.{FIELD_SUBTYPE_STRING}, " +
- $"{DB_COLUMN_TYPE_JSONB}→field.{FIELD_SUBTYPE_STRING}, " +
- $"{DB_COLUMN_TYPE_TIMESTAMP_TZ}→field.{FIELD_SUBTYPE_TIMESTAMP}",
+ $"{DB_COLUMN_TYPE_JSONB}→field.{FIELD_SUBTYPE_STRING}",
ErrorCode.ERR_BAD_ATTR_VALUE,
Envelope: field.Source));
}
diff --git a/server/csharp/MetaObjects/Meta/MetaField.cs b/server/csharp/MetaObjects/Meta/MetaField.cs
index 686dc9c6d..3649745aa 100644
--- a/server/csharp/MetaObjects/Meta/MetaField.cs
+++ b/server/csharp/MetaObjects/Meta/MetaField.cs
@@ -65,7 +65,7 @@ public string? DbColumn
///
/// R6 Plan 2b — physical DB column-type override (own-only @dbColumnType):
- /// uuid / jsonb / timestamp_with_tz, or null when absent. Own-only
+ /// uuid / jsonb, or null when absent. Own-only
/// (a physical attr is never inherited via extends:), matching the loader's
/// pairing-validation policy. The logical subtype and its native binding are unaffected.
///
diff --git a/server/csharp/MetaObjects/Persistence/Db/DbConstants.cs b/server/csharp/MetaObjects/Persistence/Db/DbConstants.cs
index 368e17fe1..2c49a4a45 100644
--- a/server/csharp/MetaObjects/Persistence/Db/DbConstants.cs
+++ b/server/csharp/MetaObjects/Persistence/Db/DbConstants.cs
@@ -20,6 +20,15 @@ public static class DbConstants
/// When true, suppress the @filterable-without-index Loader warning (Project D drift check).
public const string FIELD_ATTR_DB_INDEXED = "db.indexed";
+ ///
+ /// ADR-0036 Wave 2 — the naive-timestamp opt-out on field.timestamp (@localTime,
+ /// boolean). When true, the timestamp is a naive wall-clock value (timestamp without time
+ /// zone + CLR DateTime); absent/false (the default) = an absolute instant
+ /// (timestamptz + CLR DateTimeOffset). Replaces the retired
+ /// @dbColumnType: timestamp_with_tz escape hatch.
+ ///
+ public const string FIELD_ATTR_LOCAL_TIME = "localTime";
+
// -----------------------------------------------------------------------
// Physical RDB index/constraint attrs the db provider adds to identity.*
// (TypeRegistry.Extend on identity subtypes) — pure physical-storage
@@ -49,10 +58,11 @@ public static class DbConstants
///
/// Physical DB column-type override on a field (@dbColumnType). Closed value
- /// set: / /
- /// .
+ /// set: / .
/// Array-ness is derived from isArray: true on the field, not from a separate
/// uuid_array/text_array value (removed in Phase 1 of the slim-and-derive pass).
+ /// The retired timestamp_with_tz value is gone — timezone-awareness lives in
+ /// field.timestamp (instant by default) + @localTime per ADR-0036 Wave 2.
///
public const string FIELD_ATTR_DB_COLUMN_TYPE = "dbColumnType";
@@ -60,14 +70,11 @@ public static class DbConstants
public const string DB_COLUMN_TYPE_UUID = "uuid";
/// @dbColumnType: jsonb — genuinely-open JSON column (legal on field.string).
public const string DB_COLUMN_TYPE_JSONB = "jsonb";
- /// @dbColumnType: timestamp_with_tz — timestamp with time zone column (legal on field.timestamp).
- public const string DB_COLUMN_TYPE_TIMESTAMP_TZ = "timestamp_with_tz";
/// The closed set of legal @dbColumnType values.
public static readonly IReadOnlyList VALID_DB_COLUMN_TYPES = new[]
{
DB_COLUMN_TYPE_UUID,
DB_COLUMN_TYPE_JSONB,
- DB_COLUMN_TYPE_TIMESTAMP_TZ,
};
}
diff --git a/server/csharp/MetaObjects/Persistence/Db/DbProvider.cs b/server/csharp/MetaObjects/Persistence/Db/DbProvider.cs
index 977a2e319..c401f0536 100644
--- a/server/csharp/MetaObjects/Persistence/Db/DbProvider.cs
+++ b/server/csharp/MetaObjects/Persistence/Db/DbProvider.cs
@@ -42,6 +42,15 @@ public void RegisterTypes(TypeRegistry registry)
attributes: [DbSchema.ColumnSchema, DbSchema.DbIndexedSchema, DbSchema.DbColumnTypeSchema]);
}
+ // ADR-0036 Wave 2 — @localTime (the naive-timestamp opt-out) is field.timestamp-ONLY
+ // (it has no meaning on any other subtype), mirroring db.json's field.timestamp extends
+ // block. Default field.timestamp is an absolute instant (timestamptz / DateTimeOffset);
+ // @localTime:true opts into a naive wall-clock value (timestamp / DateTime).
+ registry.Extend(
+ MetaObjects.Shared.BaseTypes.TYPE_FIELD,
+ FieldConstants.FIELD_SUBTYPE_TIMESTAMP,
+ attributes: [DbSchema.LocalTimeSchema]);
+
// Physical RDB index/constraint attrs on identity subtypes — DB-domain
// concerns (index ordering / partial predicate / FK constraint naming),
// NOT core identity. Mirror the TS db.json identity extends.
diff --git a/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs b/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs
index 403629fcc..0e1512924 100644
--- a/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs
+++ b/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs
@@ -39,11 +39,27 @@ public static class DbSchema
Required: false,
AllowedValues: [.. DbConstants.VALID_DB_COLUMN_TYPES],
Description:
- "Physical DB column-type override (ADR-0013 escape hatch). Legal values are " +
- string.Join(" | ", DbConstants.VALID_DB_COLUMN_TYPES) + ", each legal only on a specific " +
- "logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). " +
- "The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) " +
- "are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1).");
+ "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, " +
+ "both on field.string (uuid = native Postgres uuid column over a string-typed field; " +
+ "jsonb = genuinely-open JSON column). The logical field type and its native binding are " +
+ "unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from " +
+ "a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — " +
+ "timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive " +
+ "opt-out), per ADR-0036 Wave 2.");
+
+ ///
+ /// ADR-0036 Wave 2: @localTime — the naive-timestamp opt-out on field.timestamp
+ /// (boolean, no allowedValues). Registered ONLY on field.timestamp by .
+ ///
+ public static readonly AttrSchema LocalTimeSchema = new AttrSchema(
+ Name: DbConstants.FIELD_ATTR_LOCAL_TIME,
+ ValueType: AttrConstants.ATTR_SUBTYPE_BOOLEAN,
+ Required: false,
+ Description:
+ "When true, the timestamp is a naive wall-clock value with no timezone (Postgres " +
+ "`timestamp without time zone`); absent/false (the default) = an absolute instant " +
+ "(`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz " +
+ "escape hatch.");
// --- Physical RDB index/constraint attrs the db provider adds to identity.* ---
diff --git a/server/csharp/MetaObjects/SpecMetamodel/db.json b/server/csharp/MetaObjects/SpecMetamodel/db.json
index d874ff512..a0675f689 100644
--- a/server/csharp/MetaObjects/SpecMetamodel/db.json
+++ b/server/csharp/MetaObjects/SpecMetamodel/db.json
@@ -7,7 +7,14 @@
"children": [
{ "type": "attr", "subType": "string", "name": "column", "min": 0, "max": 1, "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." },
{ "type": "attr", "subType": "boolean", "name": "db.indexed", "min": 0, "max": 1, "description": "When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means)." },
- { "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb", "timestamp_with_tz"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)." }
+ { "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2." }
+ ]
+ },
+ {
+ "type": "field",
+ "subType": "timestamp",
+ "children": [
+ { "type": "attr", "subType": "boolean", "name": "localTime", "min": 0, "max": 1, "description": "When true, the timestamp is a naive wall-clock value with no timezone (Postgres `timestamp without time zone`); absent/false (the default) = an absolute instant (`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz escape hatch." }
]
},
{
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
index 5b4f59547..7ddf20289 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt
@@ -56,10 +56,10 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase()
@@ -103,7 +103,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase` + `Table.instantWithTimeZone(...)` extension) is
// `internal`, so every `*Table.kt` in the same package + module shares the single
// declaration with no redeclaration / private-access clash.
@@ -114,7 +114,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase`
+ * instant (default, non-`@localTime`) columns: an `internal` custom `ColumnType`
* (DDL `TIMESTAMP WITH TIME ZONE`) plus the `internal Table.instantWithTimeZone(name)`
* column-builder extension that the generated table files call. `internal` keeps it
* package+module-private (no leakage) while letting EVERY `*Table.kt` in the package use
@@ -150,7 +150,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase` type is inferred).
@@ -430,11 +430,11 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase` that delegates ALL
* value/JDBC handling (read, bind, normalize, millisecond-truncate, wire string) to
@@ -586,7 +586,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase` whose SQL DDL is `TIMESTAMP WITH TIME ZONE`.
| * Delegates all value/JDBC handling to Exposed's `JavaInstantColumnType` and
| * overrides only `sqlType()`, so the column type matches the `Instant` data-class
@@ -621,7 +621,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase` backed by
+ | * Column builder for instant (default, non-`@localTime`): a `Column` backed by
| * a `TIMESTAMP WITH TIME ZONE` Postgres column (see [MetaInstantWithTimeZoneColumnType]).
| */
|internal fun Table.instantWithTimeZone(name: String): Column =
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
index ccf18d4cb..cc534240d 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
@@ -197,6 +197,12 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase LocalDateTime? = { s -> runCatching { LocalDateTime.parse(s, ${shortName}TimestampFmt) }.getOrNull() }\n")
+ if (timestampElementType == "Instant") {
+ out.append(" val parse: (String) -> Instant? = { s ->\n")
+ out.append(" val withZone = if (s.endsWith(\"Z\") || s.contains(\"+\")) s else s + \"Z\"\n")
+ out.append(" runCatching { Instant.parse(withZone) }.getOrNull()\n")
+ out.append(" }\n")
+ } else {
+ out.append(" val parse: (String) -> LocalDateTime? = { s -> runCatching { LocalDateTime.parse(s, ${shortName}TimestampFmt) }.getOrNull() }\n")
+ }
out.append(" if (op == \"in\") {\n")
out.append(" val parts = raw.split(\",\").map { it.trim() }\n")
out.append(" val list = parts.map { parse(it) ?: return null }\n")
diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt
index 1722336c9..65a80a635 100644
--- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt
+++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapper.kt
@@ -72,20 +72,20 @@ object KotlinTypeMapper {
* - `jsonb` (on [StringField]) — emit Exposed `jsonb("col", { it }, { it })` (a real
* Postgres `JSONB` column). The property stays a raw-JSON `String`; the identity
* encode/decode functions pass the JSON text straight through.
- * - `timestamp_with_tz` (on [TimestampField]) — emit the generated, file-local
- * `instantWithTimeZone("col")` extension (a `Column` whose DDL is
- * Postgres `timestamp with time zone`). Opt-in: the default for `field.timestamp` is
- * plain `datetime("col")` (Postgres `timestamp without time zone`, `LocalDateTime`).
- * NOTE: Exposed 0.55's native `timestampWithTimeZone(...)` is a `Column`,
- * which would MISMATCH the `Instant` data-class property emitted by [kotlinTypeName] and
- * force callers to hand-coerce `Instant`↔`OffsetDateTime`. We therefore emit a custom
- * `Column` column whose `sqlType()` is `TIMESTAMP WITH TIME ZONE` (the helper is
- * emitted file-locally by [KotlinExposedTableGenerator] — see [EXPOSED_INSTANT_TZ_FN]).
+ * Timestamp timezone-awareness is NOT a `@dbColumnType` value anymore (ADR-0036
+ * Wave 2): a `field.timestamp` is an absolute INSTANT (`Column`,
+ * file-local `instantWithTimeZone("col")`, Postgres `timestamp with time zone`) by
+ * DEFAULT, and the `@localTime:true` boolean is the rare naive opt-out (plain
+ * `datetime("col")`, Postgres `timestamp without time zone`, `LocalDateTime`). See
+ * [localTimeOptIn] / [EXPOSED_INSTANT_TZ_FN].
*
* Unknown values fall through to the default mapping for the field type.
*/
private val ATTR_DB_COLUMN_TYPE = CoreDBMetaDataProvider.DB_COLUMN_TYPE
+ /** `@localTime` boolean attr on [TimestampField] — ADR-0036 Wave 2 naive opt-out. */
+ private val ATTR_LOCAL_TIME = CoreDBMetaDataProvider.LOCAL_TIME
+
/** `@dbColumnType` value on [StringField] that selects Exposed `uuid("col")`. */
private val DB_COLUMN_TYPE_UUID = CoreDBMetaDataProvider.DB_COLUMN_TYPE_UUID
@@ -102,9 +102,6 @@ object KotlinTypeMapper {
*/
private val DB_COLUMN_TYPE_JSONB = CoreDBMetaDataProvider.DB_COLUMN_TYPE_JSONB
- /** `@dbColumnType` value on [TimestampField] that opts in to Exposed `timestampWithTimeZone("col")`. */
- private val DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ = CoreDBMetaDataProvider.DB_COLUMN_TYPE_TIMESTAMP_TZ
-
/** FQN of the Exposed `jsonb` extension function (raw-string open-JSON path). */
private const val EXPOSED_JSONB_IMPORT = "org.jetbrains.exposed.sql.json.jsonb"
@@ -125,8 +122,8 @@ object KotlinTypeMapper {
private val JSON_VALUE_TYPE = ClassName("kotlinx.serialization.json", "JsonElement")
/**
- * Name of the generated, file-local Exposed extension function emitted for a
- * `@dbColumnType=timestamp_with_tz` [TimestampField]. It returns a
+ * Name of the generated, file-local Exposed extension function emitted for an
+ * instant (default, non-`@localTime`) [TimestampField]. It returns a
* `Column` whose `sqlType()` is `TIMESTAMP WITH TIME ZONE` — so the
* column type MATCHES the `Instant` data-class property (zero `Instant`↔`OffsetDateTime`
* coercion) while keeping the TZ-aware Postgres column (offset→UTC normalization, the
@@ -225,15 +222,15 @@ object KotlinTypeMapper {
// field.time → java.time.LocalTime / Exposed `time(...)` (Postgres TIME). The
// wall-clock-only sibling of DateField; the wire form is "HH:MM:SS".
is TimeField -> ClassName("java.time", "LocalTime")
- // Default for field.timestamp is `java.time.LocalDateTime` — the zone-less
- // wall-clock shape (Postgres `timestamp without time zone`). The cross-port wire
- // value is zone-less (`yyyy-MM-dd'T'HH:mm:ss`, no `Z`), which a `java.time.Instant`
- // cannot carry — Jackson 400s deserializing it into the DTO. Opt in to
- // `Instant` (UTC instant) via `@dbColumnType=timestamp_with_tz`, paired with the
- // Exposed `timestampWithTimeZone` column. Mirrors the Java SpringTypeMapper fix.
+ // Default for field.timestamp is `java.time.Instant` — an absolute UTC instant
+ // (Postgres `timestamp with time zone`), whose cross-port wire value carries a
+ // `Z` (ADR-0036 Wave 2). The rare `@localTime:true` naive opt-out is the zone-less
+ // wall-clock shape (`yyyy-MM-dd'T'HH:mm:ss`, no `Z`), which is `java.time.LocalDateTime`
+ // (an Instant cannot carry a zone-less wall clock — Jackson 400s on it), paired with
+ // the Exposed `datetime` column. Mirrors the Java SpringTypeMapper flip.
is TimestampField ->
- if (timestampWithTzOptIn(field)) ClassName("java.time", "Instant")
- else ClassName("java.time", "LocalDateTime")
+ if (localTimeOptIn(field)) ClassName("java.time", "LocalDateTime")
+ else ClassName("java.time", "Instant")
// Currency: integer minor units on the wire (project-wide invariant). Same JVM
// representation as Long; surfaced as its own arm so the semantic is documented
// and downstream tooling can branch on subtype.
@@ -304,7 +301,9 @@ object KotlinTypeMapper {
BooleanField.SUBTYPE_BOOLEAN -> BOOLEAN
DateField.SUBTYPE_DATE -> ClassName("java.time", "LocalDate")
TimeField.SUBTYPE_TIME -> ClassName("java.time", "LocalTime")
- TimestampField.SUBTYPE_TIMESTAMP -> ClassName("java.time", "LocalDateTime")
+ // A map's @valueType:timestamp follows the field.timestamp DEFAULT — an
+ // absolute instant (ADR-0036 Wave 2). @localTime has no place on a map value.
+ TimestampField.SUBTYPE_TIMESTAMP -> ClassName("java.time", "Instant")
UuidField.SUBTYPE_UUID -> ClassName("java.util", "UUID")
else -> null
}
@@ -340,17 +339,17 @@ object KotlinTypeMapper {
// field.time → Exposed `time(...)` (javatime extension; needs an explicit import,
// same as `date(...)`).
is TimeField -> "org.jetbrains.exposed.sql.javatime.time"
- // Default for field.timestamp is `datetime(...)` — Postgres `timestamp without
- // time zone`, mapped by exposed-java-time to `java.time.LocalDateTime` (the
- // zone-less wall-clock shape carried on the cross-port wire) — needs the javatime
- // `datetime` import. Opt-in `@dbColumnType=timestamp_with_tz` emits the file-local
- // `instantWithTimeZone(...)` extension instead (a `Column` with
- // `TIMESTAMP WITH TIME ZONE` DDL — see [EXPOSED_INSTANT_TZ_FN]). That helper is
+ // Default for field.timestamp (ADR-0036 Wave 2) emits the file-local
+ // `instantWithTimeZone(...)` extension — a `Column` with
+ // `TIMESTAMP WITH TIME ZONE` DDL (see [EXPOSED_INSTANT_TZ_FN]). That helper is
// emitted into the table's own file by [KotlinExposedTableGenerator], so it needs
- // NO external import — return null for the opt-in branch.
+ // NO external import — return null for the default branch. The rare `@localTime:true`
+ // naive opt-out emits `datetime(...)` — Postgres `timestamp without time zone`,
+ // mapped by exposed-java-time to `java.time.LocalDateTime` (the zone-less wall-clock
+ // shape) — which needs the javatime `datetime` import.
is TimestampField -> {
- if (timestampWithTzOptIn(field)) null
- else "org.jetbrains.exposed.sql.javatime.datetime"
+ if (localTimeOptIn(field)) "org.jetbrains.exposed.sql.javatime.datetime"
+ else null
}
// `@dbColumnType=jsonb` on a field.string emits the `jsonb(...)` extension, which
// needs the exposed-json import. `@dbColumnType=uuid` maps to `uuid(...)`, a Table
@@ -429,15 +428,15 @@ object KotlinTypeMapper {
is DateField -> "date(\"$colName\")"
// field.time → Exposed `time(name)` (Postgres TIME, java.time.LocalTime).
is TimeField -> "time(\"$colName\")"
- // Default for field.timestamp is `datetime(...)` — Postgres `timestamp without
- // time zone` (`java.time.LocalDateTime`), the zone-less wall-clock wire shape.
- // Opt in to TZ-aware (`java.time.Instant`) via `@dbColumnType=timestamp_with_tz`,
- // which emits the file-local `instantWithTimeZone(...)` extension — a
+ // Default for field.timestamp is TZ-aware (`java.time.Instant`) — ADR-0036 Wave 2:
+ // emits the file-local `instantWithTimeZone(...)` extension, a
// `Column` (matches the Instant data class) whose DDL is
- // `TIMESTAMP WITH TIME ZONE` (see [EXPOSED_INSTANT_TZ_FN]).
+ // `TIMESTAMP WITH TIME ZONE` (see [EXPOSED_INSTANT_TZ_FN]). The rare `@localTime:true`
+ // naive opt-out emits `datetime(...)` — Postgres `timestamp without time zone`
+ // (`java.time.LocalDateTime`), the zone-less wall-clock shape.
is TimestampField -> {
- if (timestampWithTzOptIn(field)) "$EXPOSED_INSTANT_TZ_FN(\"$colName\")"
- else "datetime(\"$colName\")"
+ if (localTimeOptIn(field)) "datetime(\"$colName\")"
+ else "$EXPOSED_INSTANT_TZ_FN(\"$colName\")"
}
// Currency stored as BIGINT minor units — same as Long. Separate arm for
// semantic clarity (a future migration generator can branch on it).
@@ -471,7 +470,7 @@ object KotlinTypeMapper {
* Read the `@dbColumnType` attribute (case-folded) for column-type overrides. Resolved
* THROUGH the `extends` super-field chain (`includeParent = true`), so an `object.projection`
* field that binds a base-entity column via `extends:` inherits that column's physical type
- * (uuid / timestamp_with_tz) — exactly as it already inherits `@maxLength` (see
+ * (uuid / jsonb) — exactly as it already inherits `@maxLength` (see
* [stringMaxLengthOrNull]) and `isArray` (see [isArrayResolved]). Own value still wins
* (checked first by [MetaData.hasMetaAttr]). Returns null when absent. See
* [ATTR_DB_COLUMN_TYPE] for recognised values.
@@ -501,21 +500,35 @@ object KotlinTypeMapper {
}
/**
- * True iff [field] carries `@dbColumnType=timestamp_with_tz` (case-insensitive).
+ * True iff [field] carries `@localTime=true` (ADR-0036 Wave 2) — the naive wall-clock
+ * opt-out. Absent/false (the default) = an absolute instant. Resolved THROUGH the
+ * `extends` chain (so a projection field inherits the base column's tz/naive shape).
* Centralised so the type spec and the import set stay in lockstep — both call this.
*/
- private fun timestampWithTzOptIn(field: MetaField<*>): Boolean =
- dbColumnType(field) == DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ
+ private fun localTimeOptIn(field: MetaField<*>): Boolean =
+ booleanAttr(field, ATTR_LOCAL_TIME)
/**
- * True iff [field] is a [TimestampField] that opted in to the TZ-aware
- * `@dbColumnType=timestamp_with_tz` column. [KotlinExposedTableGenerator] uses this to
- * decide whether a table file must carry the file-local `instantWithTimeZone(...)`
- * support helper (the custom `Column` / `TIMESTAMP WITH TIME ZONE`
- * column type). Non-timestamp fields are always false.
+ * True iff [field] is a [TimestampField] using the instant/TZ-aware column — i.e. the
+ * DEFAULT (ADR-0036 Wave 2): every `field.timestamp` NOT flagged `@localTime:true`.
+ * [KotlinExposedTableGenerator] uses this to decide whether a table file must carry the
+ * file-local `instantWithTimeZone(...)` support helper (the custom
+ * `Column` / `TIMESTAMP WITH TIME ZONE` column type). Non-timestamp
+ * fields are always false.
*/
fun usesInstantWithTimeZone(field: MetaField<*>): Boolean =
- field is TimestampField && timestampWithTzOptIn(field)
+ field is TimestampField && !localTimeOptIn(field)
+
+ /**
+ * Best-effort read of a named boolean attribute on [field], resolved THROUGH the `extends`
+ * super-field chain (own value wins). Returns false when absent/unparseable. Used for the
+ * `@localTime` naive-timestamp opt-out.
+ */
+ private fun booleanAttr(field: MetaField<*>, name: String): Boolean {
+ if (!field.hasMetaAttr(name, true)) return false
+ val raw = runCatching { field.getMetaAttr(name, true).value }.getOrNull() ?: return false
+ return raw.toString().trim().toBoolean()
+ }
/**
* Best-effort read of a named string attribute on [field] (own-only by default;
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGeneratorTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGeneratorTest.kt
index ab46087b9..5c79761d0 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGeneratorTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGeneratorTest.kt
@@ -727,10 +727,11 @@ class KotlinExposedTableGeneratorTest {
* the generated file compile-fails with "unresolved reference: date / timestamp".
* See also the comment on [KotlinTypeMapper.exposedColumnImport].
*
- * Default for `field.timestamp` is `datetime(...)` (Postgres `timestamp without
- * time zone`, java.time.LocalDateTime) — the zone-less wall-clock wire shape. Column
- * names are snake_case-d for Postgres convention. The opt-in TZ-aware variant is
- * covered by [timestampFieldWithDbColumnTypeTimestampWithTzEmitsTzVariant].
+ * The `@localTime:true` naive opt-out on a `field.timestamp` selects `datetime(...)`
+ * (Postgres `timestamp without time zone`, java.time.LocalDateTime) — the zone-less
+ * wall-clock wire shape (ADR-0036 Wave 2). Column names are snake_case-d for Postgres
+ * convention. The DEFAULT (instant/TZ-aware) variant is covered by
+ * [timestampFieldDefaultsToInstantTzColumn].
*/
@Test fun dateAndTimestampFieldsEmitJavatimeImports() {
val withDateAndTs = """{
@@ -738,7 +739,7 @@ class KotlinExposedTableGeneratorTest {
{ "object.entity": { "name": "Event", "children": [
{ "field.long": { "name": "id" } },
{ "field.date": { "name": "occursOn" } },
- { "field.timestamp": { "name": "loggedAt" } },
+ { "field.timestamp": { "name": "loggedAt", "@localTime": true } },
{ "source.rdb": { "@table": "events" } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
] } }
@@ -758,10 +759,10 @@ class KotlinExposedTableGeneratorTest {
"expected javatime.date import for field.date; saw:\n$src")
assertTrue("import org.jetbrains.exposed.sql.javatime.datetime\n" in src ||
src.endsWith("import org.jetbrains.exposed.sql.javatime.datetime"),
- "expected javatime.datetime import for default field.timestamp; saw:\n$src")
- // Default `field.timestamp` MUST NOT bring in the timestampWithTimeZone variant.
- assertTrue("timestampWithTimeZone" !in src,
- "default field.timestamp should NOT emit timestampWithTimeZone; saw:\n$src")
+ "expected javatime.datetime import for @localTime field.timestamp; saw:\n$src")
+ // @localTime naive `field.timestamp` MUST NOT bring in the instant TZ variant.
+ assertTrue("instantWithTimeZone" !in src,
+ "@localTime field.timestamp should NOT emit instantWithTimeZone; saw:\n$src")
// Column names are snake_case-d for Postgres convention.
assertTrue("val occursOn = date(\"occurs_on\")" in src, src)
assertTrue("val loggedAt = datetime(\"logged_at\")" in src, src)
@@ -771,7 +772,7 @@ class KotlinExposedTableGeneratorTest {
}
/**
- * Opt-in: `@dbColumnType=timestamp_with_tz` on a `field.timestamp` selects a
+ * Default (ADR-0036 Wave 2): a plain `field.timestamp` selects a
* `Column` column whose Postgres DDL is `timestamp with time zone`.
* The emitted column function is the `instantWithTimeZone("col")` extension (a custom
* `ColumnType`), NOT Exposed's native `timestampWithTimeZone(...)` (which is
@@ -782,12 +783,12 @@ class KotlinExposedTableGeneratorTest {
* visibility, same package), so the table file itself carries NEITHER the helper NOR the
* Instant/Column/javatime imports — it just calls the same-package internal extension.
*/
- @Test fun timestampFieldWithDbColumnTypeTimestampWithTzEmitsInstantColumn() {
+ @Test fun timestampFieldDefaultsToInstantTzColumn() {
val tzFixture = """{
"metadata.root": { "package": "x", "children": [
{ "object.entity": { "name": "Event", "children": [
{ "field.long": { "name": "id" } },
- { "field.timestamp": { "name": "occurredAt", "@dbColumnType": "timestamp_with_tz" } },
+ { "field.timestamp": { "name": "occurredAt" } },
{ "source.rdb": { "@table": "events" } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
] } }
@@ -831,7 +832,7 @@ class KotlinExposedTableGeneratorTest {
/**
* Regression for the multi-table-per-package redeclaration bug: when TWO entities in the
- * SAME package each carry a `@dbColumnType=timestamp_with_tz` column, the earlier per-file
+ * SAME package each carry a default (instant/TZ-aware) timestamp column, the earlier per-file
* inline emission emitted the top-level `MetaInstantWithTimeZoneColumnType` class +
* `instantWithTimeZone` extension into BOTH `*Table.kt` files → redeclaration + private-access
* compile errors (162 errors in a real consumer). The fix emits the helper ONCE PER PACKAGE
@@ -848,14 +849,14 @@ class KotlinExposedTableGeneratorTest {
"metadata.root": { "package": "acme::audit", "children": [
{ "object.entity": { "name": "Role", "children": [
{ "field.long": { "name": "id" } },
- { "field.timestamp": { "name": "createdAt", "@dbColumnType": "timestamp_with_tz" } },
+ { "field.timestamp": { "name": "createdAt" } },
{ "source.rdb": { "@table": "roles" } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
] } },
{ "object.entity": { "name": "UserAuthToken", "children": [
{ "field.long": { "name": "id" } },
- { "field.timestamp": { "name": "issuedAt", "@dbColumnType": "timestamp_with_tz" } },
- { "field.timestamp": { "name": "expiresAt", "@dbColumnType": "timestamp_with_tz" } },
+ { "field.timestamp": { "name": "issuedAt" } },
+ { "field.timestamp": { "name": "expiresAt" } },
{ "source.rdb": { "@table": "user_auth_tokens" } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
] } }
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionExtendsInheritanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionExtendsInheritanceTest.kt
index 1a36ee21e..a30ea360e 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionExtendsInheritanceTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinProjectionExtendsInheritanceTest.kt
@@ -14,7 +14,7 @@ import kotlin.test.assertTrue
*
* - `@dbColumnType=uuid` → `uuid("col")` (NOT `varchar(col, 36)`)
* - `isArray:true` (string) → `array(...)` (NOT `varchar`/`text`)
- * - `@dbColumnType=timestamp_with_tz` → `instantWithTimeZone(...)` (NOT `datetime`)
+ * - a default (instant/TZ-aware) `field.timestamp` → `instantWithTimeZone(...)` (NOT `datetime`)
* - a `field.enum` super → the projection's OWN `Status` enum
* (`ProgramViewStatus`, self-contained, values inherited via `extends`), NOT a
* root-package `Status` (the cross-entity-collision bug).
@@ -35,7 +35,7 @@ import kotlin.test.assertTrue
*/
class KotlinProjectionExtendsInheritanceTest {
- // Program (writable base) carries uuid / isArray-string / timestamp_with_tz / enum fields.
+ // Program (writable base) carries uuid / isArray-string / instant-timestamp / enum fields.
// ProgramView projects them via `extends:` with NO own physical shaping — every
// physical type below MUST be inherited from the base field.
private val fixture = """{
@@ -46,7 +46,7 @@ class KotlinProjectionExtendsInheritanceTest {
{ "field.enum": { "name": "status", "@required": true,
"@values": ["DRAFT", "LIVE", "ARCHIVED"] } },
{ "field.string": { "name": "tags", "isArray": true } },
- { "field.timestamp": { "name": "publishedAt", "@dbColumnType": "timestamp_with_tz" } },
+ { "field.timestamp": { "name": "publishedAt" } },
{ "source.rdb": { "@table": "programs" } },
{ "identity.primary": { "name": "id", "@fields": "id" } }
] } },
@@ -88,9 +88,9 @@ class KotlinProjectionExtendsInheritanceTest {
assertTrue("val tags = array(\"tags\"" in table,
"tags must inherit @dbColumnType=text_array (array); saw:\n$table")
- // --- @dbColumnType=timestamp_with_tz inherited ---
+ // --- default (instant/TZ-aware) timestamp inherited ---
assertTrue("val publishedAt = instantWithTimeZone(\"published_at\")" in table,
- "publishedAt must inherit @dbColumnType=timestamp_with_tz (instantWithTimeZone); saw:\n$table")
+ "publishedAt must inherit the instant/TZ-aware timestamp (instantWithTimeZone); saw:\n$table")
// --- enum gets the projection's OWN self-contained type, not root Status ---
assertTrue("enumerationByName(\"status\", " in table && "ProgramViewStatus::class" in table,
diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
index bca1ce1ce..db89ee2f4 100644
--- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
+++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinTypeMapperTest.kt
@@ -1,5 +1,6 @@
package com.metaobjects.generator.kotlin
+import com.metaobjects.attr.BooleanAttribute
import com.metaobjects.attr.IntAttribute
import com.metaobjects.attr.StringAttribute
import com.metaobjects.field.BooleanField
@@ -85,22 +86,24 @@ class KotlinTypeMapperTest {
assertEquals("LocalTime", tn.simpleName)
}
- @Test fun `timestamp field defaults to java time LocalDateTime`() {
- // Default for field.timestamp is zone-less LocalDateTime (Postgres `timestamp
- // without time zone`) — the cross-port wire value is zone-less (no `Z`), which an
- // Instant cannot carry. Opt in to Instant via @dbColumnType=timestamp_with_tz.
+ @Test fun `timestamp field defaults to java time Instant`() {
+ // ADR-0036 Wave 2: field.timestamp is an absolute Instant by DEFAULT (Postgres
+ // `timestamp with time zone`, UTC `Z` wire form). The naive zone-less LocalDateTime
+ // shape is now the @localTime opt-out.
val f = TimestampField("createdAt")
val tn = KotlinTypeMapper.kotlinTypeName(f) as ClassName
assertEquals("java.time", tn.packageName)
- assertEquals("LocalDateTime", tn.simpleName)
+ assertEquals("Instant", tn.simpleName)
}
- @Test fun `timestamp field with dbColumnType=timestamp_with_tz maps to java time Instant`() {
- val f = TimestampField("createdAt")
- f.addMetaAttr(StringAttribute.create("dbColumnType", "timestamp_with_tz"))
+ @Test fun `timestamp field with localTime=true maps to java time LocalDateTime`() {
+ // The rare @localTime:true naive opt-out is the zone-less wall-clock shape (Postgres
+ // `timestamp without time zone`, no `Z`), which an Instant cannot carry.
+ val f = TimestampField("observedAt")
+ f.addMetaAttr(BooleanAttribute.create("localTime", true))
val tn = KotlinTypeMapper.kotlinTypeName(f) as ClassName
assertEquals("java.time", tn.packageName)
- assertEquals("Instant", tn.simpleName)
+ assertEquals("LocalDateTime", tn.simpleName)
}
@Test fun `string field with no maxLength maps to text exposed column`() {
@@ -181,38 +184,49 @@ class KotlinTypeMapperTest {
)
}
- @Test fun `timestamp field defaults to datetime exposed column with snake_case column name`() {
- // Default for field.timestamp is `datetime(...)` (Postgres `timestamp without time
- // zone`, java.time.LocalDateTime — the zone-less wall-clock wire shape). Column name
- // is snake_case-d.
- val f = TimestampField("createdAt")
- assertEquals("datetime(\"created_at\")", KotlinTypeMapper.exposedColumnSpec(f))
- }
-
- @Test fun `timestamp field with dbColumnType=timestamp_with_tz emits instantWithTimeZone`() {
- // Opt-in: `@dbColumnType=timestamp_with_tz` selects a `Column` column whose
+ @Test fun `timestamp field defaults to instantWithTimeZone exposed column`() {
+ // ADR-0036 Wave 2: a plain field.timestamp DEFAULTS to a `Column` column whose
// Postgres DDL is `timestamp with time zone`. The emitted column function is the
// file-local `instantWithTimeZone(...)` extension (a custom ColumnType), NOT
// Exposed's native `timestampWithTimeZone(...)` — that one is Column
// and would MISMATCH the `Instant` data-class property, forcing Instant↔OffsetDateTime
- // coercion at every callsite.
+ // coercion at every callsite. Column name is snake_case-d.
val f = TimestampField("createdAt")
- f.addMetaAttr(StringAttribute.create("dbColumnType", "timestamp_with_tz"))
assertEquals("instantWithTimeZone(\"created_at\")", KotlinTypeMapper.exposedColumnSpec(f))
// The helper is emitted into the table's own file by KotlinExposedTableGenerator, so the
- // column function needs NO external import (the javatime import is gone).
+ // column function needs NO external import.
assertEquals(null, KotlinTypeMapper.exposedColumnImport(f))
- // The data-class property type stays Instant (unchanged — the wire/DTO contract is correct).
+ // The data-class property type is Instant (the wire/DTO contract).
assertEquals(ClassName("java.time", "Instant"), KotlinTypeMapper.kotlinTypeName(f))
// And the table generator is told this field needs the file-local support helper.
assertEquals(true, KotlinTypeMapper.usesInstantWithTimeZone(f))
}
- @Test fun `timestamp field default import is javatime datetime`() {
+ @Test fun `timestamp field with localTime=true emits datetime exposed column`() {
+ // The rare @localTime:true naive opt-out selects `datetime(...)` (Postgres `timestamp
+ // without time zone`, java.time.LocalDateTime — the zone-less wall-clock wire shape).
+ val f = TimestampField("observedAt")
+ f.addMetaAttr(BooleanAttribute.create("localTime", true))
+ assertEquals("datetime(\"observed_at\")", KotlinTypeMapper.exposedColumnSpec(f))
+ // The javatime `datetime` import is required for the naive opt-out.
+ assertEquals("org.jetbrains.exposed.sql.javatime.datetime", KotlinTypeMapper.exposedColumnImport(f))
+ // The data-class property type is LocalDateTime.
+ assertEquals(ClassName("java.time", "LocalDateTime"), KotlinTypeMapper.kotlinTypeName(f))
+ // A naive timestamp does NOT need the instantWithTimeZone support helper.
+ assertEquals(false, KotlinTypeMapper.usesInstantWithTimeZone(f))
+ }
+
+ @Test fun `timestamp field default import is null (file-local instantWithTimeZone helper)`() {
+ // ADR-0036 Wave 2: a default field.timestamp emits the file-local instantWithTimeZone
+ // extension (same-package, no external import). The naive @localTime opt-out needs the
+ // javatime datetime import instead.
val f = TimestampField("createdAt")
+ assertEquals(null, KotlinTypeMapper.exposedColumnImport(f))
+ val naive = TimestampField("observedAt")
+ naive.addMetaAttr(BooleanAttribute.create("localTime", true))
assertEquals(
"org.jetbrains.exposed.sql.javatime.datetime",
- KotlinTypeMapper.exposedColumnImport(f),
+ KotlinTypeMapper.exposedColumnImport(naive),
)
}
diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/Author.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/Author.kt
index 789b7e4dc..36d38d160 100644
--- a/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/Author.kt
+++ b/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/Author.kt
@@ -3,8 +3,8 @@ package acme.demo
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
+import java.time.Instant
import java.time.LocalDate
-import java.time.LocalDateTime
import kotlin.Boolean
import kotlin.Double
import kotlin.Int
@@ -27,5 +27,5 @@ public data class Author(
public val active: Boolean? = null,
public val ratio: Double? = null,
public val birthday: LocalDate? = null,
- public val createdAt: LocalDateTime? = null,
+ public val createdAt: Instant? = null,
)
diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/AuthorTable.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/AuthorTable.kt
index 6c1e11d7d..13602e11a 100644
--- a/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/AuthorTable.kt
+++ b/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/AuthorTable.kt
@@ -2,7 +2,6 @@ package acme.demo
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.javatime.date
-import org.jetbrains.exposed.sql.javatime.datetime
/** GENERATED — do not hand-edit. Regenerated from metadata. */
object AuthorTable : Table("authors") {
@@ -13,7 +12,7 @@ object AuthorTable : Table("authors") {
val active = bool("active").nullable()
val ratio = double("ratio").nullable()
val birthday = date("birthday").nullable()
- val createdAt = datetime("created_at").nullable()
+ val createdAt = instantWithTimeZone("created_at").nullable()
override val primaryKey = PrimaryKey(id)
}
diff --git a/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/MetaInstantWithTimeZoneColumnType.kt b/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/MetaInstantWithTimeZoneColumnType.kt
new file mode 100644
index 000000000..62ad205d3
--- /dev/null
+++ b/server/java/codegen-kotlin/src/test/resources/snapshots/single-entity-primitives/acme/demo/MetaInstantWithTimeZoneColumnType.kt
@@ -0,0 +1,46 @@
+package acme.demo
+
+import java.time.Instant
+import org.jetbrains.exposed.sql.Column
+import org.jetbrains.exposed.sql.ColumnType
+import org.jetbrains.exposed.sql.IDateColumnType
+import org.jetbrains.exposed.sql.Table
+import org.jetbrains.exposed.sql.javatime.JavaInstantColumnType
+import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi
+import org.jetbrains.exposed.sql.vendors.currentDialect
+
+/**
+ * GENERATED — do not hand-edit.
+ * Custom Exposed column type for instant (default, non-`@localTime`): a
+ * `Column` whose SQL DDL is `TIMESTAMP WITH TIME ZONE`.
+ * Delegates all value/JDBC handling to Exposed's `JavaInstantColumnType` and
+ * overrides only `sqlType()`, so the column type matches the `Instant` data-class
+ * property (no Instant↔OffsetDateTime coercion) while staying timezone-aware.
+ */
+internal class MetaInstantWithTimeZoneColumnType :
+ ColumnType(),
+ IDateColumnType {
+ private val delegate = JavaInstantColumnType()
+ override val hasTimePart: Boolean get() = delegate.hasTimePart
+ override fun sqlType(): String =
+ currentDialect.dataTypeProvider.timestampWithTimeZoneType()
+ override fun valueFromDB(value: Any): Instant? = delegate.valueFromDB(value)
+ override fun notNullValueToDB(value: Instant): Any = delegate.notNullValueToDB(value)
+ override fun nonNullValueToString(value: Instant): String = delegate.nonNullValueToString(value)
+ override fun nonNullValueAsDefaultString(value: Instant): String =
+ delegate.nonNullValueAsDefaultString(value)
+ override fun readObject(rs: java.sql.ResultSet, index: Int): Any? = delegate.readObject(rs, index)
+ override fun setParameter(
+ stmt: PreparedStatementApi,
+ index: Int,
+ value: Any?,
+ ) = delegate.setParameter(stmt, index, value)
+}
+
+/**
+ * Column builder for instant (default, non-`@localTime`): a `Column` backed by
+ * a `TIMESTAMP WITH TIME ZONE` Postgres column (see [MetaInstantWithTimeZoneColumnType]).
+ */
+internal fun Table.instantWithTimeZone(name: String): Column =
+ registerColumn(name, MetaInstantWithTimeZoneColumnType())
+
diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
index f9456a71e..b422923be 100644
--- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
+++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java
@@ -91,16 +91,16 @@ public static String javaTypeName(MetaField> field) {
// KotlinTypeMapper's `time` arm; previously absent, so any time-bearing entity
// hit the unsupported-type throw (SP-H Unit 5 fix).
if (field instanceof TimeField) return "java.time.LocalTime";
- // Timestamp wire contract (normalization.md): plain `field.timestamp` is
- // "timestamp WITHOUT time zone" → wall-clock ISO string with NO `Z`
- // (e.g. "2026-01-01T10:00:00"), which round-trips as java.time.LocalDateTime.
- // The `@dbColumnType=timestamp_with_tz` opt-in is "timestamp WITH time zone"
- // → UTC `Z` form, which is java.time.Instant. Using Instant for the default
- // (no-tz) case is wrong: Instant can neither parse nor emit a zone-less string,
- // so the DTO can't accept the cross-port `createdAt` wire value. Mirrors
- // KotlinTypeMapper's timestamp/timestampWithTimeZone split.
+ // Timestamp wire contract (normalization.md, ADR-0036 Wave 2): plain
+ // `field.timestamp` is an absolute INSTANT (timestamp WITH time zone → UTC
+ // `Z` wire form, e.g. "2026-01-01T10:00:00Z"), which round-trips as
+ // java.time.Instant — the default. The `@localTime:true` opt-out is a naive
+ // wall-clock value (timestamp WITHOUT time zone → zone-less ISO with NO `Z`,
+ // e.g. "2026-01-01T10:00:00"), which is java.time.LocalDateTime: Instant can
+ // neither parse nor emit a zone-less string. Mirrors KotlinTypeMapper's
+ // timestampWithTimeZone/datetime split.
if (field instanceof TimestampField) {
- return timestampWithTzOptIn(field) ? "java.time.Instant" : "java.time.LocalDateTime";
+ return localTimeOptIn(field) ? "java.time.LocalDateTime" : "java.time.Instant";
}
// Currency wire/JVM type: Long (integer minor units cross-port invariant).
if (field instanceof CurrencyField) return "Long";
@@ -118,17 +118,16 @@ public static String javaTypeName(MetaField> field) {
}
/**
- * True iff {@code field} carries {@code @dbColumnType=timestamp_with_tz}
- * (case-insensitive) — the opt-in to "timestamp WITH time zone" (UTC `Z`
- * wire form → {@code java.time.Instant}). Mirrors
- * {@code KotlinTypeMapper.timestampWithTzOptIn}. Own-only read; absent /
- * non-attribute → {@code false} (plain no-tz timestamp).
+ * True iff {@code field} carries {@code @localTime=true} (ADR-0036 Wave 2) — the
+ * naive wall-clock opt-out ("timestamp WITHOUT time zone" → zone-less wire form →
+ * {@code java.time.LocalDateTime}). Absent/false (the default) = an absolute
+ * instant ({@code java.time.Instant}). Mirrors {@code KotlinTypeMapper.localTimeOptIn}.
+ * Own-only read; absent / non-attribute → {@code false} (the instant default).
*/
- private static boolean timestampWithTzOptIn(MetaField> field) {
- if (!field.hasMetaAttr(CoreDBMetaDataProvider.DB_COLUMN_TYPE)) return false;
- Object raw = field.getMetaAttr(CoreDBMetaDataProvider.DB_COLUMN_TYPE).getValue();
- return raw != null
- && CoreDBMetaDataProvider.DB_COLUMN_TYPE_TIMESTAMP_TZ.equalsIgnoreCase(String.valueOf(raw).trim());
+ private static boolean localTimeOptIn(MetaField> field) {
+ if (!field.hasMetaAttr(CoreDBMetaDataProvider.LOCAL_TIME)) return false;
+ Object raw = field.getMetaAttr(CoreDBMetaDataProvider.LOCAL_TIME).getValue();
+ return raw != null && Boolean.parseBoolean(String.valueOf(raw).trim());
}
/**
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDtoGeneratorTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDtoGeneratorTest.java
index f860bfb75..12ec895f9 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDtoGeneratorTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDtoGeneratorTest.java
@@ -69,10 +69,10 @@ public void emitsJavaRecordWithCorrectFieldTypes() throws Exception {
assertTrue("expected `Long id` component; saw:\n" + src, src.contains("Long id"));
assertTrue("expected `String name` component; saw:\n" + src, src.contains("String name"));
assertTrue("expected `String bio` component; saw:\n" + src, src.contains("String bio"));
- // Plain field.timestamp → LocalDateTime (no-tz wall-clock wire contract; see
- // SpringTypeMapper). Was Instant; Instant can't carry the zone-less `createdAt`.
- assertTrue("expected `java.time.LocalDateTime createdAt` component; saw:\n" + src,
- src.contains("java.time.LocalDateTime createdAt"));
+ // Plain field.timestamp → Instant (ADR-0036 Wave 2: instant/tz-aware by default;
+ // the naive LocalDateTime shape is now the @localTime opt-out). See SpringTypeMapper.
+ assertTrue("expected `java.time.Instant createdAt` component; saw:\n" + src,
+ src.contains("java.time.Instant createdAt"));
}
@Test
diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java
index 126247714..79a05d6af 100644
--- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java
+++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringTypeMapperTest.java
@@ -57,27 +57,27 @@ public void dateFieldMapsToLocalDate() {
}
@Test
- public void timestampFieldMapsToLocalDateTime() {
- // Plain `field.timestamp` is "timestamp WITHOUT time zone" — the wire form is a
- // zone-less wall-clock ISO string with NO `Z` (normalization.md). java.time.Instant
- // would force a UTC `Z` and can't even parse a zone-less string, so the no-tz default
- // maps to java.time.LocalDateTime (matches KotlinTypeMapper's plain `timestamp(...)`).
- assertEquals("java.time.LocalDateTime", SpringTypeMapper.javaTypeName(new TimestampField("createdAt")));
+ public void timestampFieldMapsToInstant() {
+ // ADR-0036 Wave 2: a plain `field.timestamp` is an absolute INSTANT by DEFAULT —
+ // "timestamp WITH time zone", UTC `Z` wire form → java.time.Instant.
+ assertEquals("java.time.Instant", SpringTypeMapper.javaTypeName(new TimestampField("createdAt")));
}
@Test
- public void timestampFieldWithTzOptInMapsToInstant() {
- // Opt-in `@dbColumnType=timestamp_with_tz` is "timestamp WITH time zone" — the wire
- // form is the UTC `Z` instant, i.e. java.time.Instant.
- TimestampField f = new TimestampField("createdAt");
- f.addMetaAttr(com.metaobjects.attr.StringAttribute.create("dbColumnType", "timestamp_with_tz"));
- assertEquals("java.time.Instant", SpringTypeMapper.javaTypeName(f));
+ public void timestampFieldWithLocalTimeOptOutMapsToLocalDateTime() {
+ // The rare `@localTime:true` naive opt-out is "timestamp WITHOUT time zone" — the wire
+ // form is a zone-less wall-clock ISO string with NO `Z`, i.e. java.time.LocalDateTime
+ // (an Instant can't parse a zone-less string). Matches KotlinTypeMapper's `datetime(...)`.
+ TimestampField f = new TimestampField("observedAt");
+ f.addMetaAttr(com.metaobjects.attr.BooleanAttribute.create("localTime", true));
+ assertEquals("java.time.LocalDateTime", SpringTypeMapper.javaTypeName(f));
}
@Test
- public void timestampFieldTzOptInIsCaseInsensitive() {
+ public void timestampFieldLocalTimeFalseMapsToInstant() {
+ // @localTime:false is the explicit form of the instant default → java.time.Instant.
TimestampField f = new TimestampField("createdAt");
- f.addMetaAttr(com.metaobjects.attr.StringAttribute.create("dbColumnType", "TIMESTAMP_WITH_TZ"));
+ f.addMetaAttr(com.metaobjects.attr.BooleanAttribute.create("localTime", false));
assertEquals("java.time.Instant", SpringTypeMapper.javaTypeName(f));
}
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt
index 2ab5ba6da..500d03a1a 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/KotlinCodegenMatchesReferenceTest.kt
@@ -83,7 +83,7 @@ internal class KotlinCodegenMatchesReferenceTest {
),
// R6 Plan 2a/2b native physical column types: field.uuid → Exposed `uuid(...)`,
// field.string + @dbColumnType:uuid → `uuid(...)`, field.string + @dbColumnType:jsonb
- // → `jsonb(...)` (NOT text), field.timestamp + @dbColumnType:timestamp_with_tz →
+ // → `jsonb(...)` (NOT text), default field.timestamp (instant/TZ-aware, ADR-0036 Wave 2) →
// `instantWithTimeZone(...)` (a file-local Column with TIMESTAMP WITH TIME ZONE
// DDL — matches the Instant data class, NOT the native OffsetDateTime variant). Verifies
// the generator emits the native families the hand-written reference AssetTable carries.
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt
index 8b81916d1..4c119127e 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/RuntimeReturnTypeTest.kt
@@ -90,7 +90,7 @@ class RuntimeReturnTypeTest {
assertNotNull(recordedAt, "Asset.recordedAt should be present")
assertTrue(
recordedAt is Instant,
- "field.timestamp Asset.recordedAt (TIMESTAMPTZ via @dbColumnType:timestamp_with_tz) " +
+ "field.timestamp Asset.recordedAt (TIMESTAMPTZ — instant by default, ADR-0036 Wave 2) " +
"must be a native java.time.Instant — the metaobjects `instantWithTimeZone` " +
"Column path matches the Instant data class (NOT OffsetDateTime, NOT a " +
"String). Got: ${recordedAt::class}",
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/AuthorApiServer.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/AuthorApiServer.kt
index c7c8d458f..d94b14d65 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/AuthorApiServer.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/AuthorApiServer.kt
@@ -15,13 +15,14 @@ import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
-import org.jetbrains.exposed.sql.javatime.datetime
+import org.jetbrains.exposed.sql.javatime.timestamp
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import java.net.InetSocketAddress
import java.sql.DriverManager
-import java.time.LocalDateTime
+import java.time.Instant
+import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
@@ -235,8 +236,9 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
"id" to row[AuthorTable.id],
"name" to row[AuthorTable.name],
"bio" to row[AuthorTable.bio],
- // Normalize to ISO-8601 without zone (matches the seed/wire format).
- "createdAt" to ts.format(TIMESTAMP_FMT),
+ // ADR-0036 Wave 2: createdAt is an absolute instant — normalize to the UTC wire
+ // form yyyy-MM-ddTHH:mm:ssZ.
+ "createdAt" to formatInstant(ts),
)
}
@@ -286,11 +288,16 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
return ValidSort(field, dir)
}
- private fun parseTimestamp(s: String): LocalDateTime {
- // The corpus uses `yyyy-MM-ddTHH:mm:ss` (no zone) for wall-clock times.
- return LocalDateTime.parse(s, TIMESTAMP_FMT)
+ private fun parseTimestamp(s: String): Instant {
+ // Corpus values are offset-less wall-clock (yyyy-MM-ddTHH:mm:ss); per the instant
+ // wire contract an offset-less value is interpreted as UTC (append Z).
+ val withZone = if (s.endsWith("Z") || s.contains("+")) s else s + "Z"
+ return Instant.parse(withZone)
}
+ private fun formatInstant(instant: Instant): String =
+ TIMESTAMP_FMT.format(instant.atOffset(ZoneOffset.UTC)) + "Z"
+
private object InvalidSort
private data class ValidSort(val field: String, val dir: SortOrder)
@@ -369,7 +376,7 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
/** Coerce a single non-list, non-isNull value into the per-subtype Kotlin type. */
private fun coerceScalar(raw: String, subType: String): Any? = when (subType) {
"string" -> raw
- "datetime" -> runCatching { LocalDateTime.parse(raw, TIMESTAMP_FMT) }.getOrNull()
+ "datetime" -> runCatching { parseTimestamp(raw) }.getOrNull()
"number" -> raw.toLongOrNull()
"boolean" -> when (raw) { "true" -> true; "false" -> false; else -> null }
else -> null
@@ -436,15 +443,15 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
}
@Suppress("UNCHECKED_CAST")
- private fun SqlExpressionBuilder.datetimeColOp(col: Column, p: FilterPredicate): Op =
+ private fun SqlExpressionBuilder.datetimeColOp(col: Column, p: FilterPredicate): Op =
when (p.op) {
- "eq" -> col eq (p.value as LocalDateTime)
- "ne" -> col neq (p.value as LocalDateTime)
- "gt" -> col greater (p.value as LocalDateTime)
- "gte" -> col greaterEq (p.value as LocalDateTime)
- "lt" -> col less (p.value as LocalDateTime)
- "lte" -> col lessEq (p.value as LocalDateTime)
- "in" -> col inList (p.value as List)
+ "eq" -> col eq (p.value as Instant)
+ "ne" -> col neq (p.value as Instant)
+ "gt" -> col greater (p.value as Instant)
+ "gte" -> col greaterEq (p.value as Instant)
+ "lt" -> col less (p.value as Instant)
+ "lte" -> col lessEq (p.value as Instant)
+ "in" -> col inList (p.value as List)
"isNull" -> if (p.value as Boolean) col.isNull() else col.isNotNull()
else -> throw IllegalStateException("unsupported op for datetime col: ${p.op}")
}
@@ -479,7 +486,7 @@ class AuthorApiServer(private val pg: PostgresContainer) : AutoCloseable {
val id = long("id").autoIncrement()
val name = varchar("name", 100)
val bio = varchar("bio", 1000).nullable()
- val createdAt = datetime("createdAt")
+ val createdAt = timestamp("createdAt")
override val primaryKey = PrimaryKey(id)
}
diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt
index ae19733b0..23c229406 100644
--- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt
+++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/generated/GeneratedAuthorControllerHarness.kt
@@ -1,7 +1,11 @@
package com.metaobjects.integration.kotlin.api.generated
+import com.fasterxml.jackson.core.JsonParser
+import com.fasterxml.jackson.databind.DeserializationContext
+import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import com.metaobjects.generator.kotlin.KotlinEntityGenerator
@@ -26,6 +30,7 @@ import java.net.URI
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
+import java.time.Instant
import java.util.concurrent.atomic.AtomicInteger
import kotlin.io.path.isRegularFile
import kotlin.io.path.readText
@@ -73,10 +78,27 @@ class GeneratedAuthorControllerHarness(
private val seedRows: List
*/
public static final java.util.List VALID_DB_COLUMN_TYPES = java.util.List.of(
- DB_COLUMN_TYPE_UUID, DB_COLUMN_TYPE_JSONB, DB_COLUMN_TYPE_TIMESTAMP_TZ);
+ DB_COLUMN_TYPE_UUID, DB_COLUMN_TYPE_JSONB);
@Override
public String getProviderId() {
diff --git a/server/java/metadata/src/main/java/com/metaobjects/field/TimestampField.java b/server/java/metadata/src/main/java/com/metaobjects/field/TimestampField.java
index 28c54b96e..169a3edf8 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/field/TimestampField.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/field/TimestampField.java
@@ -16,6 +16,7 @@
package com.metaobjects.field;
import com.metaobjects.*;
+import com.metaobjects.attr.BooleanAttribute;
import com.metaobjects.attr.IntAttribute;
import com.metaobjects.attr.StringAttribute;
import com.metaobjects.constraint.PlacementConstraint;
@@ -45,6 +46,16 @@ public class TimestampField extends PrimitiveField {
public final static String ATTR_MIN_DATE = "minDate";
public final static String ATTR_MAX_DATE = "maxDate";
+ /**
+ * {@code @localTime} (boolean) — ADR-0036 Wave 2. When true, the timestamp is a
+ * naive wall-clock value with no timezone ({@code timestamp without time zone} /
+ * {@code LocalDateTime}); absent/false (the default) = an absolute instant
+ * ({@code timestamptz} / {@code Instant}). Replaces the retired
+ * {@code @dbColumnType: timestamp_with_tz} escape hatch. Description/enrichment
+ * for the registry manifest is sourced from {@code spec/metamodel/db.json}.
+ */
+ public final static String ATTR_LOCAL_TIME = com.metaobjects.database.CoreDBMetaDataProvider.LOCAL_TIME;
+
/**
* Register TimestampField type with the registry (called by provider)
@@ -60,6 +71,13 @@ public static void registerTypes(MetaDataRegistry registry) {
.ofType(IntAttribute.SUBTYPE_INT)
.asSingle();
+ // @localTime (boolean) — ADR-0036 Wave 2 naive opt-out (timestamp WITHOUT
+ // time zone); absent/false = the instant/tz default. Scoped to
+ // field.timestamp; allowedValues/description enriched from spec db.json.
+ def.optionalAttributeWithConstraints(ATTR_LOCAL_TIME)
+ .ofType(BooleanAttribute.SUBTYPE_BOOLEAN)
+ .asSingle();
+
// The field-level @dateFormat (presentation) and @minDate/@maxDate (range)
// attrs had no canonical peer and no consumer (codegen / runtime / loader)
// — vestigial. Range validation is expressed via a validator child
diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
index 90fb642f8..d9fcc61bd 100644
--- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
+++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java
@@ -666,12 +666,12 @@ private static boolean parsesAsFiniteNumber(String s) {
// Own-only: validates the @dbColumnType attribute declared on THIS field node
// (not inherited), mirroring the field.enum @values pass. Two rules:
//
- // 1. The value must be one of the closed set uuid|jsonb|timestamp_with_tz
- // → ERR_BAD_ATTR_VALUE otherwise.
+ // 1. The value must be one of the closed set uuid|jsonb
+ // → ERR_BAD_ATTR_VALUE otherwise. (The timestamp_with_tz value was retired
+ // in ADR-0036 Wave 2 — it now trips Rule 1 as an unrecognized value.)
// 2. The value's legal (subtype × dbColumnType) pairing must hold:
- // uuid → field.string
- // jsonb → field.string
- // timestamp_with_tz → field.timestamp
+ // uuid → field.string
+ // jsonb → field.string
// → ERR_BAD_ATTR_VALUE on an illegal pairing.
//
// The error message names the field, the value, and the legal set — matching
@@ -717,7 +717,6 @@ private static void validateDbColumnTypeNode(MetaData node) {
String requiredSubType = switch (value) {
case CoreDBMetaDataProvider.DB_COLUMN_TYPE_UUID,
CoreDBMetaDataProvider.DB_COLUMN_TYPE_JSONB -> StringField.SUBTYPE_STRING;
- case CoreDBMetaDataProvider.DB_COLUMN_TYPE_TIMESTAMP_TZ -> TimestampField.SUBTYPE_TIMESTAMP;
default -> null; // unreachable — Rule 1 already rejected unknown values
};
if (requiredSubType != null && !requiredSubType.equals(subType)) {
@@ -727,8 +726,7 @@ private static void validateDbColumnTypeNode(MetaData node) {
+ "' @" + CoreDBMetaDataProvider.DB_COLUMN_TYPE + " '" + value
+ "' is not valid on field." + subType
+ " (requires field." + requiredSubType + "); allowed pairings: "
- + "uuid→field.string, jsonb→field.string, "
- + "timestamp_with_tz→field.timestamp",
+ + "uuid→field.string, jsonb→field.string",
ErrorCode.ERR_BAD_ATTR_VALUE, node.getSource());
}
}
diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/DbColumnTypeValidationTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/DbColumnTypeValidationTest.java
index cf6d52330..2d8668682 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/loader/DbColumnTypeValidationTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/loader/DbColumnTypeValidationTest.java
@@ -31,7 +31,8 @@
* Tests for {@link ValidationPhase#validateDbColumnType} (R6 Plan 2b). The
* {@code @dbColumnType} physical attribute is validated own-only against:
*
- * - a closed value set ({@code uuid|jsonb|timestamp_with_tz}); and
+ * - a closed value set ({@code uuid|jsonb} — {@code timestamp_with_tz} was retired
+ * in ADR-0036 Wave 2); and
* - the legal (logical-subtype × value) pairing.
*
* Both violations surface {@link ErrorCode#ERR_BAD_ATTR_VALUE}, mirroring the
@@ -85,7 +86,11 @@ public void jsonbOnStringIsLegal() {
}
@Test
- public void timestampTzOnTimestampIsLegal() {
+ public void retiredTimestampTzValueIsRejected() {
+ // ADR-0036 Wave 2: timestamp_with_tz is a RETIRED @dbColumnType value (the legal set
+ // shrank to {uuid, jsonb}). It no longer loads on field.timestamp — it trips Rule 1
+ // (unrecognized value) → ERR_BAD_ATTR_VALUE. Timezone-awareness now lives in
+ // field.timestamp (instant by default) + @localTime (the naive opt-out).
String canonical = "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" +
" { \"object.entity\": { \"name\": \"Asset\", \"children\": [" +
" { \"field.long\": { \"name\": \"id\" } }," +
@@ -93,14 +98,31 @@ public void timestampTzOnTimestampIsLegal() {
" { \"identity.primary\": { \"@fields\": \"id\" } }" +
" ] } }" +
"] } }";
- loadThrough(canonical, "timestamptz-on-timestamp-ok.json"); // must not throw
+ assertBadAttrValue(canonical, "retired-timestamptz-rejected.json");
+ }
+
+ @Test
+ public void localTimeBooleanOnTimestampIsLegal() {
+ // ADR-0036 Wave 2: @localTime (boolean) is the naive wall-clock opt-out on
+ // field.timestamp — registered, so it loads cleanly (strict-provenance friendly).
+ String canonical = "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" +
+ " { \"object.entity\": { \"name\": \"Asset\", \"children\": [" +
+ " { \"field.long\": { \"name\": \"id\" } }," +
+ " { \"field.timestamp\": { \"name\": \"observedAt\", \"@localTime\": true } }," +
+ " { \"identity.primary\": { \"@fields\": \"id\" } }" +
+ " ] } }" +
+ "] } }";
+ loadThrough(canonical, "localtime-on-timestamp-ok.json"); // must not throw
}
// ---- Illegal pairings + unknown value → ERR_BAD_ATTR_VALUE -----------
@Test
- public void timestampTzOnStringIsIllegalPairing() {
+ public void timestampTzOnStringIsRejected() {
// The shared error-dbcolumntype-illegal-pairing fixture exercises this exact shape.
+ // Post ADR-0036 Wave 2 the trigger shifted from an illegal pairing (Rule 2) to an
+ // unrecognized value (Rule 1 — timestamp_with_tz is retired), but it is still
+ // ERR_BAD_ATTR_VALUE either way (the shared fixture asserts only the code).
String canonical = "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" +
" { \"object.entity\": { \"name\": \"Asset\", \"children\": [" +
" { \"field.long\": { \"name\": \"id\" } }," +
@@ -211,10 +233,12 @@ public void noDbColumnTypeIsAlwaysFine() {
@Test
public void errorMessageNamesFieldValueAndLegalSet() {
+ // A genuine illegal pairing (Rule 2): uuid is legal only on field.string, so on
+ // field.timestamp the message names the field, the value, and the required subtype.
String canonical = "{ \"metadata.root\": { \"package\": \"acme\", \"children\": [" +
" { \"object.entity\": { \"name\": \"Asset\", \"children\": [" +
" { \"field.long\": { \"name\": \"id\" } }," +
- " { \"field.string\": { \"name\": \"recordedAt\", \"@dbColumnType\": \"timestamp_with_tz\" } }," +
+ " { \"field.timestamp\": { \"name\": \"recordedAt\", \"@dbColumnType\": \"uuid\" } }," +
" { \"identity.primary\": { \"@fields\": \"id\" } }" +
" ] } }" +
"] } }";
@@ -224,9 +248,9 @@ public void errorMessageNamesFieldValueAndLegalSet() {
} catch (MetaDataException e) {
String msg = e.getMessage();
assertTrue("message should name the field: " + msg, msg.contains("recordedAt"));
- assertTrue("message should name the value: " + msg, msg.contains("timestamp_with_tz"));
+ assertTrue("message should name the value: " + msg, msg.contains("uuid"));
assertTrue("message should name the required subtype: " + msg,
- msg.contains("field.timestamp"));
+ msg.contains("field.string"));
}
}
}
diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java
index f2ef26beb..916c5747d 100644
--- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java
+++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/SimpleMappingHandlerDB.java
@@ -145,7 +145,7 @@ protected void loadColumns( Collection fields, BaseTableDef table, Ob
// Create the column definition
ColumnDef colDef = new ColumnDef( col, getSQLType( mf ));
- // R6 Plan 2a/2b: physical column-type hint (native uuid/jsonb/timestamptz).
+ // R6 Plan 2a/2b: physical column-type hint (native uuid/jsonb).
// Drives the driver's native-column emission; the SQLType above still
// drives JDBC get/set so the field round-trips as its logical value.
colDef.setDbColumnType( resolveDbColumnType( mf ));
@@ -229,9 +229,15 @@ else if ( ObjectManager.AUTO_UPDATE.equals( auto )) {
*
* - {@code field.uuid} (logical subtype) → native {@code uuid} column.
* - {@code @dbColumnType} physical attr → the mapped hint
- * ({@code uuid} / {@code jsonb} / {@code timestamp_with_tz}). The loader has
- * already validated the (subtype × value) pairing, so no re-check here.
+ * ({@code uuid} / {@code jsonb}). The loader has already validated the
+ * (subtype × value) pairing, so no re-check here.
*
+ *
+ * Timestamp timezone-awareness is NOT a {@code @dbColumnType} hint anymore
+ * (ADR-0036 Wave 2): {@code field.timestamp} is an instant ({@code timestamptz})
+ * by default and {@code @localTime:true} is the naive opt-out — the
+ * {@link com.metaobjects.manager.db.codec.JdbcCodecs.TimestampCodec} reads that
+ * flag off the field directly.
*/
protected String resolveDbColumnType(MetaField mf) {
if (com.metaobjects.field.UuidField.SUBTYPE_UUID.equals(mf.getSubType())) {
@@ -241,7 +247,6 @@ protected String resolveDbColumnType(MetaField mf) {
String value = mf.getMetaAttr(CoreDBMetaDataProvider.DB_COLUMN_TYPE).getValueAsString();
if (CoreDBMetaDataProvider.DB_COLUMN_TYPE_UUID.equals(value)) return ColumnDef.COLTYPE_UUID;
if (CoreDBMetaDataProvider.DB_COLUMN_TYPE_JSONB.equals(value)) return ColumnDef.COLTYPE_JSONB;
- if (CoreDBMetaDataProvider.DB_COLUMN_TYPE_TIMESTAMP_TZ.equals(value)) return ColumnDef.COLTYPE_TIMESTAMP_TZ;
}
return null;
}
diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/codec/JdbcCodecs.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/codec/JdbcCodecs.java
index 9bf61c25c..302a2f2e5 100644
--- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/codec/JdbcCodecs.java
+++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/codec/JdbcCodecs.java
@@ -299,61 +299,66 @@ static final class StringCodec implements JdbcFieldCodec {
* The write hazard this codec closes. Before SP-H Unit 5 TimestampField had no
* codec and fell to the generic {@link ObjectCodec}, whose {@code setObject(java.util.Date)}
* is rejected by pgjdbc ("Can't infer the SQL type to use for an instance of
- * java.util.Date") — the INSERT threw at runtime. We bind an explicit
- * {@link java.sql.Timestamp} (plain) or a UTC {@link OffsetDateTime} (tz-aware) instead.
+ * java.util.Date") — the INSERT threw at runtime. We bind an explicit UTC
+ * {@link OffsetDateTime} (instant default) or a {@link java.sql.Timestamp}
+ * ({@code @localTime} naive) instead.
+ *
+ *
ADR-0036 Wave 2. A plain {@code field.timestamp} is now an absolute
+ * INSTANT (timestamptz) by DEFAULT; the naive wall-clock form is the rare
+ * {@code @localTime:true} opt-out (timestamp without time zone). This inverts the
+ * pre-Wave-2 default (which was naive, with tz the {@code @dbColumnType:
+ * timestamp_with_tz} opt-in).
*
*
- * - Plain TIMESTAMP (no {@code @dbColumnType}): bind/read the UTC wall clock through
- * the {@code Calendar(UTC)} overloads of {@code setTimestamp}/{@code getTimestamp}. The
- * previous codec used the no-Calendar overloads, which convert the instant through the JVM
- * default zone — a symmetric round-trip on the SAME zone preserves the value (so a
- * UTC-pinned test passed) but the corpus asserts the UTC wall clock, so under a
- * non-UTC default zone the stored/read wall-clock shifted. Pinning a UTC Calendar makes the
- * wall clock stored and read verbatim regardless of the JVM zone (no {@code Z} on the wire).
- * The read value is re-based via {@code Timestamp.valueOf(utcLocalDateTime)} so the wire
- * normalizer's {@code Timestamp.toLocalDateTime()} returns that UTC wall clock unshifted.
- * - TIMESTAMPTZ ({@code @dbColumnType: timestamp_with_tz}): bind a UTC
+ *
- TIMESTAMPTZ (default — no {@code @localTime}): bind a UTC
* {@link OffsetDateTime} via {@code setObject(.., TIMESTAMP_WITH_TIMEZONE)} so pgjdbc
* targets the {@code timestamptz} column correctly (a bare {@code setTimestamp} on a
* tz column would be interpreted in the session zone). Read stays {@code getTimestamp}
* (→ java.util.Date); {@code ObjectManagerDbAdapter.coerceWireType} lifts a tz-flagged
* value to an {@link OffsetDateTime} so {@code Normalization} appends the {@code Z}.
+ * - Plain TIMESTAMP ({@code @localTime:true}): bind/read the UTC wall clock through
+ * the {@code Calendar(UTC)} overloads of {@code setTimestamp}/{@code getTimestamp}. The
+ * no-Calendar overloads convert the instant through the JVM default zone — a symmetric
+ * round-trip on the SAME zone preserves the value (so a UTC-pinned test passed) but the
+ * corpus asserts the UTC wall clock, so under a non-UTC default zone the
+ * stored/read wall-clock shifted. Pinning a UTC Calendar makes the wall clock stored and
+ * read verbatim regardless of the JVM zone (no {@code Z} on the wire).
*
*/
static final class TimestampCodec implements JdbcFieldCodec {
@Override public void readInto(Object o, MetaField f, ResultSet rs, int j) throws SQLException {
- if (isTimestampTz(f)) {
- // tz-aware: keep the java.sql.Timestamp the read path expects (the adapter lifts a
- // tz-flagged value to an OffsetDateTime so Normalization appends the Z).
+ if (!isLocalTime(f)) {
+ // tz-aware (DEFAULT): keep the java.sql.Timestamp the read path expects (the adapter
+ // lifts a tz-flagged value to an OffsetDateTime so Normalization appends the Z).
Timestamp tv = rs.getTimestamp(j);
f.setDate(o, rs.wasNull() ? null : tv);
return;
}
- // Plain TIMESTAMP: read the stored wall clock zone-free via getTimestamp(UTC), which
- // pins the wall-clock→instant interpretation to UTC regardless of the JVM default zone.
- // The returned java.sql.Timestamp's INSTANT (getTime()/toInstant()) is the stored wall
- // clock anchored at UTC, so the wire normalizer recovers the wall clock zone-free as
+ // @localTime naive TIMESTAMP: read the stored wall clock zone-free via getTimestamp(UTC),
+ // which pins the wall-clock→instant interpretation to UTC regardless of the JVM default
+ // zone. The returned java.sql.Timestamp's INSTANT (getTime()/toInstant()) is the stored
+ // wall clock anchored at UTC, so the wire normalizer recovers the wall clock zone-free as
// `ts.toInstant().atZone(UTC)` — it does NOT use the default-zone toLocalDateTime().
// TimestampField is backed by DataTypes.DATE, so setDate accepts the Timestamp.
Timestamp tv = rs.getTimestamp(j, utcCalendar());
f.setDate(o, (rs.wasNull() || tv == null) ? null : tv);
}
@Override public void write(PreparedStatement s, MetaField f, int j, Object v) throws SQLException {
- boolean tz = isTimestampTz(f);
+ boolean naive = isLocalTime(f);
if (v == null) {
- s.setNull(j, tz ? Types.TIMESTAMP_WITH_TIMEZONE : Types.TIMESTAMP);
+ s.setNull(j, naive ? Types.TIMESTAMP : Types.TIMESTAMP_WITH_TIMEZONE);
return;
}
- if (tz) {
- // tz-aware column: bind an absolute instant as a UTC OffsetDateTime so pgjdbc
- // routes it to the timestamptz column without a session-zone reinterpretation.
+ if (!naive) {
+ // tz-aware column (DEFAULT): bind an absolute instant as a UTC OffsetDateTime so
+ // pgjdbc routes it to the timestamptz column without a session-zone reinterpretation.
long millis = (v instanceof java.util.Date d) ? d.getTime() : Long.parseLong(v.toString());
OffsetDateTime odt = java.time.Instant.ofEpochMilli(millis).atOffset(ZoneOffset.UTC);
s.setObject(j, odt, Types.TIMESTAMP_WITH_TIMEZONE);
return;
}
- // Plain TIMESTAMP: store the UTC wall clock of the instant zone-free. The field's
- // value carries an instant (java.util.Date epoch millis); its UTC wall clock is
+ // @localTime naive TIMESTAMP: store the UTC wall clock of the instant zone-free. The
+ // field's value carries an instant (java.util.Date epoch millis); its UTC wall clock is
// Instant.ofEpochMilli(millis) @ UTC. Binding a Timestamp whose getTime() IS that
// instant, with a UTC Calendar, tells the driver to store exactly that wall clock —
// no JVM-default-zone reinterpretation. A LocalDateTime value is first re-anchored to
@@ -436,12 +441,16 @@ static final class UuidCodec implements JdbcFieldCodec {
}
}
- /** True for a {@code field.timestamp} carrying {@code @dbColumnType: timestamp_with_tz}. */
- private static boolean isTimestampTz(MetaField f) {
+ /**
+ * True for a {@code field.timestamp} carrying {@code @localTime: true} — the naive
+ * wall-clock opt-out (ADR-0036 Wave 2). Absent/false (the default) = an absolute
+ * instant (timestamptz).
+ */
+ private static boolean isLocalTime(MetaField f) {
try {
- return f.hasMetaAttr(CoreDBMetaDataProvider.DB_COLUMN_TYPE)
- && CoreDBMetaDataProvider.DB_COLUMN_TYPE_TIMESTAMP_TZ.equalsIgnoreCase(
- String.valueOf(f.getMetaAttr(CoreDBMetaDataProvider.DB_COLUMN_TYPE).getValue()).trim());
+ return f.hasMetaAttr(CoreDBMetaDataProvider.LOCAL_TIME)
+ && Boolean.parseBoolean(
+ String.valueOf(f.getMetaAttr(CoreDBMetaDataProvider.LOCAL_TIME).getValue()).trim());
} catch (Exception e) {
return false;
}
diff --git a/server/java/omdb/src/main/java/com/metaobjects/manager/db/defs/ColumnDef.java b/server/java/omdb/src/main/java/com/metaobjects/manager/db/defs/ColumnDef.java
index d4ef24d1c..5438256d1 100644
--- a/server/java/omdb/src/main/java/com/metaobjects/manager/db/defs/ColumnDef.java
+++ b/server/java/omdb/src/main/java/com/metaobjects/manager/db/defs/ColumnDef.java
@@ -27,8 +27,9 @@ public class ColumnDef extends BaseArgDef {
public final static String COLTYPE_UUID = "uuid";
/** Hint: native JSONB column ({@code @dbColumnType: jsonb}, genuinely-open JSON). */
public final static String COLTYPE_JSONB = "jsonb";
- /** Hint: TIMESTAMP WITH TIME ZONE column ({@code @dbColumnType: timestamp_with_tz}). */
- public final static String COLTYPE_TIMESTAMP_TZ = "timestamp_with_tz";
+ // COLTYPE_TIMESTAMP_TZ retired (ADR-0036 Wave 2): timestamp timezone-awareness is
+ // no longer a @dbColumnType hint — field.timestamp is timestamptz by default and
+ // @localTime is the naive opt-out, read off the field by the timestamp codec.
private int length = DEFAULT_LENGTH;
private boolean isPrimaryKey = false;
@@ -105,8 +106,8 @@ public void setAutoType(int autoType) {
/**
* The dialect-neutral physical column-type hint (one of {@link #COLTYPE_UUID} /
- * {@link #COLTYPE_JSONB} / {@link #COLTYPE_TIMESTAMP_TZ}), or {@code null} to use
- * the {@link #getSQLType() SQLType} default. R6 Plan 2a/2b.
+ * {@link #COLTYPE_JSONB}), or {@code null} to use the {@link #getSQLType() SQLType}
+ * default. R6 Plan 2a/2b.
*/
public String getDbColumnType() {
return dbColumnType;
diff --git a/server/java/omdb/src/test/java/com/metaobjects/manager/db/codec/JdbcCodecRoundTripTest.java b/server/java/omdb/src/test/java/com/metaobjects/manager/db/codec/JdbcCodecRoundTripTest.java
index b2b9a4b39..912be69c7 100644
--- a/server/java/omdb/src/test/java/com/metaobjects/manager/db/codec/JdbcCodecRoundTripTest.java
+++ b/server/java/omdb/src/test/java/com/metaobjects/manager/db/codec/JdbcCodecRoundTripTest.java
@@ -321,6 +321,11 @@ public void timestampCodecIsZoneIndependentAtTheJdbcBoundary() throws Exception
com.metaobjects.field.TimestampField tsField = new com.metaobjects.field.TimestampField("tsVal") {
@Override public void setDate(Object obj, Date value) { store.put("v", value); }
};
+ // @localTime:true selects the naive ("timestamp WITHOUT time zone") codec path — the only
+ // one Derby's plain TIMESTAMP column supports. The instant/tz default (no @localTime) binds
+ // via setObject(TIMESTAMP_WITH_TIMEZONE), gated against real Postgres in persistence-conformance.
+ tsField.addMetaAttr(com.metaobjects.attr.BooleanAttribute.create(
+ com.metaobjects.database.CoreDBMetaDataProvider.LOCAL_TIME, true));
JdbcCodecs.TimestampCodec codec = new JdbcCodecs.TimestampCodec();
// The UTC wall clock the corpus asserts (no zone).
diff --git a/server/java/omdb/src/test/resources/meta.codec.json b/server/java/omdb/src/test/resources/meta.codec.json
index f01c74d86..ce2eb1124 100644
--- a/server/java/omdb/src/test/resources/meta.codec.json
+++ b/server/java/omdb/src/test/resources/meta.codec.json
@@ -77,7 +77,8 @@
{
"field.timestamp": {
"name": "tsVal",
- "@column": "tsVal"
+ "@column": "tsVal",
+ "@localTime": true
}
},
{
diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py
index cd822b684..cfd6edfb2 100644
--- a/server/python/src/metaobjects/loader/validation_passes.py
+++ b/server/python/src/metaobjects/loader/validation_passes.py
@@ -42,7 +42,6 @@
)
from ..meta.persistence.db.db_constants import (
DB_COLUMN_TYPE_JSONB,
- DB_COLUMN_TYPE_TIMESTAMP_TZ,
DB_COLUMN_TYPE_UUID,
FIELD_ATTR_DB_COLUMN_TYPE,
VALID_DB_COLUMN_TYPES,
@@ -662,25 +661,27 @@ def _validate_field_defaults(root: MetaData, errors: list[MetaError]) -> None:
# Own-only validation of the @dbColumnType physical column-type attribute,
# mirroring the field.enum @values precedent. Two rules, both → ERR_BAD_ATTR_VALUE:
#
-# 1. The value must be one of the closed set uuid|jsonb|timestamp_with_tz.
+# 1. The value must be one of the closed set uuid|jsonb.
# (@dbColumnType is registered as a bare string attr — no allowed_values — so
# this pass is the SOLE enforcer of the closed set: an unknown value fires
# exactly one ERR_BAD_ATTR_VALUE, matching TS/Java/C#.)
# 2. The (logical subtype × value) pairing must be legal:
-# uuid → field.string
-# jsonb → field.string
-# timestamp_with_tz → field.timestamp
+# uuid → field.string
+# jsonb → field.string
+#
+# ADR-0036 Wave 2: timestamp_with_tz is RETIRED — timezone-awareness moved to
+# field.timestamp (instant by default) + @localTime (the naive opt-out), so it is
+# no longer a legal @dbColumnType value or pairing.
#
# Own-only: only @dbColumnType declared on THIS node is validated (a physical
# attr is never inherited via extends:). Cross-port: TS/C#/Java run the identical
# own-only check.
-# value → the field subtype it is legal on (Phase 1: three surviving values).
+# value → the field subtype it is legal on (uuid/jsonb on field.string).
# uuid_array / text_array are REMOVED — derive from field.uuid/field.string + isArray.
_DB_COLUMN_TYPE_REQUIRED_SUBTYPE: dict[str, str] = {
DB_COLUMN_TYPE_UUID: FIELD_SUBTYPE_STRING,
DB_COLUMN_TYPE_JSONB: FIELD_SUBTYPE_STRING,
- DB_COLUMN_TYPE_TIMESTAMP_TZ: FIELD_SUBTYPE_TIMESTAMP,
}
diff --git a/server/python/src/metaobjects/meta/persistence/db/db_constants.py b/server/python/src/metaobjects/meta/persistence/db/db_constants.py
index e4e0e3705..4cc7f8c2d 100644
--- a/server/python/src/metaobjects/meta/persistence/db/db_constants.py
+++ b/server/python/src/metaobjects/meta/persistence/db/db_constants.py
@@ -18,9 +18,17 @@
# ---------------------------------------------------------------------------
# Physical DB column-type override on a field (@dbColumnType). Closed value set:
-# DB_COLUMN_TYPE_UUID / DB_COLUMN_TYPE_JSONB / DB_COLUMN_TYPE_TIMESTAMP_TZ.
+# DB_COLUMN_TYPE_UUID / DB_COLUMN_TYPE_JSONB.
FIELD_ATTR_DB_COLUMN_TYPE = "dbColumnType"
+# ADR-0036 Wave 2: @localTime — a boolean flag on field.timestamp marking the rare
+# naive / wall-clock case. When true the column is Postgres `timestamp without time
+# zone`; absent/false (the default) is an absolute instant → `timestamptz`. This
+# replaces the retired @dbColumnType: timestamp_with_tz escape hatch — timezone-
+# awareness now lives in the logical field type (instant-by-default) + this opt-out,
+# not in a physical column-type string.
+FIELD_ATTR_LOCAL_TIME = "localTime"
+
# @db.indexed — boolean DB-domain attr on every field subtype. Suppresses the
# @filterable-without-index warning by declaring an explicit index intent.
# Mirrors TS persistence/db/db-constants.ts FIELD_ATTR_DB_INDEXED.
@@ -30,8 +38,12 @@
DB_COLUMN_TYPE_UUID = "uuid"
# @dbColumnType: jsonb — genuinely-open JSON column (legal on field.string).
DB_COLUMN_TYPE_JSONB = "jsonb"
-# @dbColumnType: timestamp_with_tz — timestamp with time zone (legal on field.timestamp).
-DB_COLUMN_TYPE_TIMESTAMP_TZ = "timestamp_with_tz"
+
+# ADR-0036 Wave 2 removal: timestamp_with_tz is no longer a valid @dbColumnType
+# value. field.timestamp is instant / tz-aware BY DEFAULT and @localTime opts into
+# the naive wall-clock case — timezone-awareness moved out of @dbColumnType. The
+# constant is kept as a tombstone so any straggling caller gets a clear NameError.
+# DB_COLUMN_TYPE_TIMESTAMP_TZ = "timestamp_with_tz" # REMOVED — use @localTime opt-out
# Phase 1 removal: uuid_array / text_array are no longer valid @dbColumnType values.
# Derive native uuid[]/text[] from field.uuid/field.string + isArray=true instead.
@@ -40,11 +52,11 @@
# DB_COLUMN_TYPE_UUID_ARRAY = "uuid_array" # REMOVED — use field.uuid isArray:true
# DB_COLUMN_TYPE_TEXT_ARRAY = "text_array" # REMOVED — use field.string isArray:true
-# The closed set of legal @dbColumnType values (Phase 1: uuid | jsonb | timestamp_with_tz).
+# The closed set of legal @dbColumnType values. ADR-0036 Wave 2 retires
+# timestamp_with_tz — the legal set is now just { uuid, jsonb }, both on field.string.
VALID_DB_COLUMN_TYPES = (
DB_COLUMN_TYPE_UUID,
DB_COLUMN_TYPE_JSONB,
- DB_COLUMN_TYPE_TIMESTAMP_TZ,
)
# ---------------------------------------------------------------------------
diff --git a/server/python/src/metaobjects/meta/persistence/db/db_provider.py b/server/python/src/metaobjects/meta/persistence/db/db_provider.py
index 74938991c..d4ae96003 100644
--- a/server/python/src/metaobjects/meta/persistence/db/db_provider.py
+++ b/server/python/src/metaobjects/meta/persistence/db/db_provider.py
@@ -16,7 +16,7 @@
"""
from __future__ import annotations
-from ...core.attr.attr_constants import ATTR_SUBTYPE_STRING
+from ...core.attr.attr_constants import ATTR_SUBTYPE_BOOLEAN, ATTR_SUBTYPE_STRING
from ...core.field import field_constants as fc
from ...core.identity.identity_constants import (
IDENTITY_SUBTYPE_REFERENCE,
@@ -27,6 +27,7 @@
from ....shared.base_types import TYPE_FIELD, TYPE_IDENTITY
from .db_constants import (
FIELD_ATTR_DB_COLUMN_TYPE,
+ FIELD_ATTR_LOCAL_TIME,
IDENTITY_REFERENCE_ATTR_CONSTRAINT_NAME,
IDENTITY_SECONDARY_ATTR_ORDERS,
IDENTITY_SECONDARY_ATTR_WHERE,
@@ -46,14 +47,13 @@
_COLUMN_SCHEMA = AttrSchema(name=fc.FIELD_ATTR_COLUMN, value_type=ATTR_SUBTYPE_STRING, required=False)
# @dbColumnType — physical column-type override on a field. Carries the closed
-# value-set (uuid | jsonb | timestamp_with_tz) PURELY so it surfaces in the
-# registry manifest (ADR-0036 Wave 1, decision 5 — closed-value-set conformance
-# gate). Its REAL constraint is the (subtype × value) pairing enforced by the
-# loader's _validate_db_column_type pass, which emits the single ERR_BAD_ATTR_VALUE
-# for both an unrecognized value and an illegal pairing; that pass is the sole
-# enforcer, so @dbColumnType is EXEMPT from the generic flat allowed_values
-# membership check (Check 3 in validation_passes) to avoid double-reporting —
-# matching the TS reference.
+# value-set (uuid | jsonb) PURELY so it surfaces in the registry manifest (ADR-0036
+# Wave 1, decision 5 — closed-value-set conformance gate). Its REAL constraint is
+# the (subtype × value) pairing enforced by the loader's _validate_db_column_type
+# pass, which emits the single ERR_BAD_ATTR_VALUE for both an unrecognized value and
+# an illegal pairing; that pass is the sole enforcer, so @dbColumnType is EXEMPT from
+# the generic flat allowed_values membership check (Check 3 in validation_passes) to
+# avoid double-reporting — matching the TS reference.
_DB_COLUMN_TYPE_SCHEMA = AttrSchema(
name=FIELD_ATTR_DB_COLUMN_TYPE,
value_type=ATTR_SUBTYPE_STRING,
@@ -61,6 +61,16 @@
allowed_values=VALID_DB_COLUMN_TYPES,
)
+# @localTime — boolean opt-out into a naive / wall-clock timestamp (ADR-0036 Wave
+# 2). Registered on field.timestamp only; the description is sourced from the
+# embedded spec/metamodel/db.json by apply_spec_descriptions (single-source). NO
+# allowed_values — it's an open boolean, not a closed-enum attr.
+_LOCAL_TIME_SCHEMA = AttrSchema(
+ name=FIELD_ATTR_LOCAL_TIME,
+ value_type=ATTR_SUBTYPE_BOOLEAN,
+ required=False,
+)
+
# DB-domain physical attrs EXTENDING identity subtypes — RDB index / FK-constraint
# concerns, NOT core identity. Mirrors spec/metamodel/db.json's identity extends and
# the TS/Java/C# db providers. Descriptions are byte-identical to the canonical.
@@ -129,6 +139,13 @@ def _register(registry: TypeRegistry) -> None:
sub_type,
attributes=[_COLUMN_SCHEMA, _DB_COLUMN_TYPE_SCHEMA],
)
+ # ADR-0036 Wave 2: @localTime is a field.timestamp-only opt-out (instant by
+ # default → naive). Registered on the timestamp subtype only.
+ registry.extend(
+ TYPE_FIELD,
+ fc.FIELD_SUBTYPE_TIMESTAMP,
+ attributes=[_LOCAL_TIME_SCHEMA],
+ )
registry.extend(
TYPE_IDENTITY,
IDENTITY_SUBTYPE_SECONDARY,
diff --git a/server/python/src/metaobjects/runtime/object_manager.py b/server/python/src/metaobjects/runtime/object_manager.py
index 4eaedbb7f..ad8ad79ff 100644
--- a/server/python/src/metaobjects/runtime/object_manager.py
+++ b/server/python/src/metaobjects/runtime/object_manager.py
@@ -674,8 +674,9 @@ def _coerce_write_value(field: MetaField, value: Any) -> Any:
return value if isinstance(value, _uuid.UUID) else _uuid.UUID(str(value))
# temporal: parse the authoring string to the native type, with tz-awareness
- # driven by the field (TIMESTAMP → naive, TIMESTAMPTZ → aware) so the driver
- # binds the correct physical type.
+ # driven by the field so the driver binds the correct physical type. ADR-0036
+ # Wave 2: field.timestamp is instant / tz-aware BY DEFAULT (→ timestamptz); the
+ # rare naive wall-clock case opts out with @localTime:true (→ plain timestamp).
if sub == fc.FIELD_SUBTYPE_DATE:
return value if isinstance(value, _dt.date) else _dt.date.fromisoformat(str(value))
if sub == fc.FIELD_SUBTYPE_TIME:
@@ -683,7 +684,7 @@ def _coerce_write_value(field: MetaField, value: Any) -> Any:
if sub == fc.FIELD_SUBTYPE_TIMESTAMP:
if isinstance(value, _dt.datetime):
return value
- is_tz = col_type == dbc.DB_COLUMN_TYPE_TIMESTAMP_TZ
+ is_tz = field.attr(dbc.FIELD_ATTR_LOCAL_TIME) is not True
return _parse_datetime(str(value), tz_aware=is_tz)
# field.object (jsonb storage): a dict/list passes through — pg8000 binds it
diff --git a/server/python/src/metaobjects/spec_metamodel/db.json b/server/python/src/metaobjects/spec_metamodel/db.json
index d874ff512..a0675f689 100644
--- a/server/python/src/metaobjects/spec_metamodel/db.json
+++ b/server/python/src/metaobjects/spec_metamodel/db.json
@@ -7,7 +7,14 @@
"children": [
{ "type": "attr", "subType": "string", "name": "column", "min": 0, "max": 1, "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." },
{ "type": "attr", "subType": "boolean", "name": "db.indexed", "min": 0, "max": 1, "description": "When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means)." },
- { "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb", "timestamp_with_tz"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)." }
+ { "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2." }
+ ]
+ },
+ {
+ "type": "field",
+ "subType": "timestamp",
+ "children": [
+ { "type": "attr", "subType": "boolean", "name": "localTime", "min": 0, "max": 1, "description": "When true, the timestamp is a naive wall-clock value with no timezone (Postgres `timestamp without time zone`); absent/false (the default) = an absolute instant (`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz escape hatch." }
]
},
{
diff --git a/server/python/tests/unit/test_field_uuid_dbcolumntype.py b/server/python/tests/unit/test_field_uuid_dbcolumntype.py
index 150449765..8f002a137 100644
--- a/server/python/tests/unit/test_field_uuid_dbcolumntype.py
+++ b/server/python/tests/unit/test_field_uuid_dbcolumntype.py
@@ -90,11 +90,23 @@ def test_dbcolumntype_jsonb_on_string_ok() -> None:
assert codes == []
-def test_dbcolumntype_timestamptz_on_timestamp_ok() -> None:
+def test_dbcolumntype_timestamptz_retired_on_timestamp_error() -> None:
+ # ADR-0036 Wave 2: @dbColumnType:timestamp_with_tz is RETIRED — it is no longer
+ # a recognized value, so even on field.timestamp it is now ERR_BAD_ATTR_VALUE
+ # (unknown value). Timezone-awareness moved to @localTime (instant by default).
codes, _ = _load(_entity(
{"field.long": {"name": "id"}},
{"field.timestamp": {"name": "at", "@dbColumnType": "timestamp_with_tz"}},
))
+ assert codes == ["ERR_BAD_ATTR_VALUE"]
+
+
+def test_local_time_attr_on_timestamp_ok() -> None:
+ # ADR-0036 Wave 2: @localTime is a valid boolean opt-out on field.timestamp.
+ codes, _ = _load(_entity(
+ {"field.long": {"name": "id"}},
+ {"field.timestamp": {"name": "at", "@localTime": True}},
+ ))
assert codes == []
@@ -104,6 +116,7 @@ def test_dbcolumntype_timestamptz_on_timestamp_ok() -> None:
def test_dbcolumntype_timestamptz_on_string_illegal() -> None:
+ # ADR-0036 Wave 2: timestamp_with_tz is retired → unknown value on any subtype.
codes, _ = _load(_entity(
{"field.long": {"name": "id"}},
{"field.string": {"name": "at", "@dbColumnType": "timestamp_with_tz"}},
@@ -185,13 +198,14 @@ def test_dbcolumntype_uuid_array_error_message_names_valid_set() -> None:
result = MetaDataLoader.from_directory(tmpdir, providers=[core_provider])
assert len(result.errors) == 1
msg = result.errors[0].message
- # The "allowed:" section must list only the three surviving legal values.
+ # The "allowed:" section must list only the two surviving legal values
+ # (ADR-0036 Wave 2 retired timestamp_with_tz → { uuid, jsonb }).
allowed_idx = msg.index("allowed:")
allowed_section = msg[allowed_idx:]
assert "uuid" in allowed_section
assert "jsonb" in allowed_section
- assert "timestamp_with_tz" in allowed_section
- # uuid_array and text_array must NOT appear in the allowed section.
+ # timestamp_with_tz (retired), uuid_array, text_array must NOT appear.
+ assert "timestamp_with_tz" not in allowed_section
assert "uuid_array" not in allowed_section
assert "text_array" not in allowed_section
diff --git a/server/typescript/packages/codegen-ts/src/column-mapper.ts b/server/typescript/packages/codegen-ts/src/column-mapper.ts
index 16abd4305..69a14ba3a 100644
--- a/server/typescript/packages/codegen-ts/src/column-mapper.ts
+++ b/server/typescript/packages/codegen-ts/src/column-mapper.ts
@@ -34,7 +34,7 @@ import {
FIELD_ATTR_DB_COLUMN_TYPE,
DB_COLUMN_TYPE_UUID,
DB_COLUMN_TYPE_JSONB,
- DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ,
+ FIELD_ATTR_LOCAL_TIME,
} from "@metaobjectsdev/metadata";
import { columnNameFromField } from "./naming.js";
import { enumValues } from "./enum-meta.js";
@@ -160,19 +160,22 @@ export interface ColumnSpec {
* migrate-ts/src/expected-schema.ts (override checked first, wins over the
* subtype default), so codegen and DDL agree.
*
- * Postgres-only: uuid/jsonb/timestamptz are Postgres physical column types with
- * no native SQLite analogue, so on SQLite the attribute is ignored (the caller
- * falls through to the subtype default). The native TS binding is keyed on
+ * Postgres-only: uuid/jsonb are Postgres physical column types with no native
+ * SQLite analogue, so on SQLite the attribute is ignored (the caller falls
+ * through to the subtype default). The native TS binding is keyed on
* field.subType elsewhere and is unaffected — a `field.string @dbColumnType:uuid`
* field stays a TS `string`; only the Drizzle column function changes.
*
+ * ADR-0036 Wave 2: the `timestamp_with_tz` value is retired — `field.timestamp`
+ * is now instant/tz-aware BY DEFAULT (handled in the subtype switch below), and
+ * the naive opt-out is the `@localTime` boolean, not a physical override.
+ *
* The loader has already validated the (subtype × value) pairing, so an
* unrecognized value never reaches here; an unknown value returns undefined
* (fall through to subtype default).
*/
function pgColumnTypeOverride(
field: MetaField,
- timestampMode: "date" | "string" = "string",
): { fnName: string; fnOptions?: Record } | undefined {
const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE);
if (typeof dbColumnType !== "string") return undefined;
@@ -181,12 +184,6 @@ function pgColumnTypeOverride(
return { fnName: "uuid" };
case DB_COLUMN_TYPE_JSONB:
return { fnName: "jsonb" };
- case DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ:
- // Drizzle pg-core: timestamp(col, { withTimezone: true }) → timestamptz.
- // mode defaults to "string" (ISO-8601 wire contract, matching the generated
- // Zod); a consumer can opt into "date" (drizzle's native mode) via
- // codegen.timestampMode when its hand-written code works with JS Dates.
- return { fnName: "timestamp", fnOptions: { mode: timestampMode, withTimezone: true } };
default:
return undefined;
}
@@ -291,7 +288,7 @@ export function mapColumnType(
// A physical @dbColumnType override wins over the subtype default (Postgres
// only; SQLite has no native analogue and falls through above). Resolved
// first so the override-precedence matches migrate-ts's expected-schema.
- const override = pgColumnTypeOverride(field, timestampMode);
+ const override = pgColumnTypeOverride(field);
if (override !== undefined) {
// Override fully determines the physical type; skip the subtype switch.
fnName = override.fnName;
@@ -322,6 +319,11 @@ export function mapColumnType(
fnName = "time";
break;
case FIELD_SUBTYPE_TIMESTAMP:
+ // ADR-0036 Wave 2: field.timestamp is instant / tz-aware BY DEFAULT →
+ // timestamp({ withTimezone: true }) = timestamptz. A naive wall-clock
+ // value opts out with @localTime:true → timestamp({ withTimezone:
+ // false }) = `timestamp without time zone`.
+ //
// mode:"string" so the column round-trips ISO-8601 strings — the
// generated Zod schema validates timestamp fields as z.string() and
// the cross-port wire format carries timestamps as JSON strings.
@@ -330,7 +332,10 @@ export function mapColumnType(
// inconsistent with the string-typed schema + wire contract and
// throws on a string write. See SP-B api-contract-generated lane.
fnName = "timestamp";
- fnOptions = { mode: timestampMode };
+ fnOptions = {
+ mode: timestampMode,
+ withTimezone: field.attr(FIELD_ATTR_LOCAL_TIME) !== true,
+ };
break;
case FIELD_SUBTYPE_UUID:
// Postgres native uuid column; native TS binding stays `string`.
diff --git a/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts b/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts
index cb10d3c37..6fae4bfd9 100644
--- a/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts
+++ b/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts
@@ -160,7 +160,7 @@ function neutralTypeCell(field: MetaField): string {
/** Neutral PHYSICAL type cell for the Storage table. Metadata-driven, no DDL
* re-derivation (ADR-0020): if the field declares a `@dbColumnType` physical
- * override (e.g. `uuid`, `jsonb`, `timestamp_with_tz`) show it UPPERCASED;
+ * override (e.g. `uuid`, `jsonb`) show it UPPERCASED;
* otherwise fall back to the same neutral LOGICAL type the Constraints table
* uses. Deliberately does NOT derive ANSI/ORM SQL so it can't drift vs the
* migrate engine or re-introduce language-specific DDL. Wrapped in backticks. */
diff --git a/server/typescript/packages/codegen-ts/test/column-mapper.test.ts b/server/typescript/packages/codegen-ts/test/column-mapper.test.ts
index 9cec7c8e0..21fd251ed 100644
--- a/server/typescript/packages/codegen-ts/test/column-mapper.test.ts
+++ b/server/typescript/packages/codegen-ts/test/column-mapper.test.ts
@@ -266,15 +266,22 @@ describe("mapColumnType — @dbColumnType physical override (R6 Plan 2b)", () =>
expect(f.subType).toBe(FIELD_SUBTYPE_STRING);
});
- test("Postgres: @dbColumnType timestamp_with_tz → timestamp({ mode: 'string', withTimezone: true })", () => {
+ test("Postgres: plain field.timestamp → timestamp({ mode: 'string', withTimezone: true }) (instant by default, ADR-0036 Wave 2)", () => {
const f = metaField(FIELD_SUBTYPE_TIMESTAMP, "createdAt");
- f.setAttr("dbColumnType", "timestamp_with_tz");
const spec = mapColumnType(f, "postgres");
expect(spec.fnName).toBe("timestamp");
- // mode:"string" keeps the column round-tripping ISO strings (matches the
- // string-typed Zod schema + the cross-port wire contract).
+ // tz-aware by default → timestamptz; mode:"string" keeps ISO-string round-trip
+ // (matches the string-typed Zod schema + the cross-port wire contract).
expect(spec.fnOptions).toEqual({ mode: "string", withTimezone: true });
- // field.timestamp binds to `string` natively — the override is physical-only.
+ expect(f.subType).toBe(FIELD_SUBTYPE_TIMESTAMP);
+ });
+
+ test("Postgres: field.timestamp @localTime → timestamp({ mode: 'string', withTimezone: false }) (naive opt-out)", () => {
+ const f = metaField(FIELD_SUBTYPE_TIMESTAMP, "wallClockAt");
+ f.setAttr("localTime", true);
+ const spec = mapColumnType(f, "postgres");
+ expect(spec.fnName).toBe("timestamp");
+ expect(spec.fnOptions).toEqual({ mode: "string", withTimezone: false });
expect(f.subType).toBe(FIELD_SUBTYPE_TIMESTAMP);
});
diff --git a/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Program.ts b/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Program.ts
index d471080fc..3d20d9dcf 100644
--- a/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Program.ts
+++ b/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Program.ts
@@ -22,7 +22,9 @@ export const programs = pgTable("programs", {
description: text("description"),
priceCents: integer("price_cents").notNull(),
isPublished: boolean("is_published").notNull().default(false),
- createdAt: timestamp("created_at", { mode: "string" }).notNull().defaultNow(),
+ createdAt: timestamp("created_at", { mode: "string", withTimezone: true })
+ .notNull()
+ .defaultNow(),
});
export const programsRelations = relations(programs, ({ many }) => ({
weeks: many(weeks),
diff --git a/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Purchase.ts b/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Purchase.ts
index 1a5d22ed9..8182d2f3c 100644
--- a/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Purchase.ts
+++ b/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Purchase.ts
@@ -23,7 +23,7 @@ export const purchases = pgTable("purchases", {
.notNull()
.references((): AnyPgColumn => programs.id),
amountCents: integer("amount_cents").notNull(),
- purchasedAt: timestamp("purchased_at", { mode: "string" })
+ purchasedAt: timestamp("purchased_at", { mode: "string", withTimezone: true })
.notNull()
.defaultNow(),
});
diff --git a/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Subscriber.ts b/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Subscriber.ts
index 7bb2f9fda..fae068a24 100644
--- a/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Subscriber.ts
+++ b/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/Subscriber.ts
@@ -19,7 +19,9 @@ export const subscribers = pgTable("subscribers", {
firstName: varchar("first_name", { length: 100 }).notNull(),
lastName: varchar("last_name", { length: 100 }),
subscribed: boolean("subscribed").notNull().default(true),
- createdAt: timestamp("created_at", { mode: "string" }).notNull().defaultNow(),
+ createdAt: timestamp("created_at", { mode: "string", withTimezone: true })
+ .notNull()
+ .defaultNow(),
});
export const subscribersRelations = relations(subscribers, ({ many }) => ({
workoutEvents: many(workoutEvents),
diff --git a/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/WorkoutEvent.ts b/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/WorkoutEvent.ts
index 984d2bb90..76042666e 100644
--- a/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/WorkoutEvent.ts
+++ b/server/typescript/packages/codegen-ts/test/golden/__snapshots__/postgres/WorkoutEvent.ts
@@ -22,7 +22,7 @@ export const workoutEvents = pgTable("workout_events", {
workoutId: bigint("workout_id", { mode: "number" })
.notNull()
.references((): AnyPgColumn => workouts.id),
- completedAt: timestamp("completed_at", { mode: "string" })
+ completedAt: timestamp("completed_at", { mode: "string", withTimezone: true })
.notNull()
.defaultNow(),
durationMinutes: integer("duration_minutes"),
diff --git a/server/typescript/packages/metadata/src/persistence/db/db-constants.ts b/server/typescript/packages/metadata/src/persistence/db/db-constants.ts
index 4769f885c..1f2563f5b 100644
--- a/server/typescript/packages/metadata/src/persistence/db/db-constants.ts
+++ b/server/typescript/packages/metadata/src/persistence/db/db-constants.ts
@@ -2,7 +2,6 @@
import {
FIELD_SUBTYPE_STRING,
- FIELD_SUBTYPE_TIMESTAMP,
} from "../../core/field/field-constants.js";
/** Column name override on a field (maps to @column in metadata). */
@@ -38,21 +37,29 @@ export const FIELD_ATTR_DB_COLUMN_TYPE = "dbColumnType";
export const DB_COLUMN_TYPE_UUID = "uuid";
/** `@dbColumnType: jsonb` — genuinely-open `jsonb` column (legal on field.string). */
export const DB_COLUMN_TYPE_JSONB = "jsonb";
-/** `@dbColumnType: timestamp_with_tz` — `timestamp with time zone` (legal on field.timestamp). */
-export const DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ = "timestamp_with_tz";
+
+/**
+ * ADR-0036 Wave 2: `@localTime` — a boolean flag on `field.timestamp` marking the
+ * rare naive / wall-clock case. When true the column is Postgres `timestamp
+ * without time zone`; absent/false (the default) is an absolute instant →
+ * `timestamptz`. This replaces the retired `@dbColumnType: timestamp_with_tz`
+ * escape hatch — timezone-awareness now lives in the logical field type
+ * (instant-by-default) + this opt-out, not in a physical column-type string.
+ */
+export const FIELD_ATTR_LOCAL_TIME = "localTime";
/**
* The closed set of legal `@dbColumnType` values (raw-dialect passthrough deferred).
* dbColumnType slim-and-derive (ADR-0036 Wave 1, decision 4): the native-array
* overrides `uuid_array`/`text_array` are fully removed — native `uuid[]`/`text[]`
* are DERIVED from a field subtype + `isArray`, never declared via an escape hatch.
- * `uuid` and `jsonb` stay on field.string; `timestamp_with_tz` stays on
- * field.timestamp (its instant-default flip is Wave 2).
+ * Wave 2 (decision 1) retires `timestamp_with_tz` entirely — tz-awareness moves to
+ * `field.timestamp` (instant by default) + `@localTime` (the naive opt-out). The
+ * legal set is now just `{ uuid, jsonb }`, both on field.string.
*/
export const DB_COLUMN_TYPE_VALUES = [
DB_COLUMN_TYPE_UUID,
DB_COLUMN_TYPE_JSONB,
- DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ,
] as const;
export type DbColumnTypeValue = (typeof DB_COLUMN_TYPE_VALUES)[number];
@@ -60,10 +67,9 @@ export type DbColumnTypeValue = (typeof DB_COLUMN_TYPE_VALUES)[number];
* Legal `@dbColumnType` value → the field subtypes it may be applied to (ADR-0013,
* R6 Plan 2b). Any other (subtype × value) pairing — or an unrecognized value — is
* an ERR_BAD_ATTR_VALUE. Keyed by value; the value-set is enforced by membership in
- * this map. Subtype names are the bare FIELD_SUBTYPE_* constants (string / timestamp).
+ * this map. Subtype names are the bare FIELD_SUBTYPE_* constants (string).
*/
export const DB_COLUMN_TYPE_LEGAL_SUBTYPES: Readonly> = {
[DB_COLUMN_TYPE_UUID]: [FIELD_SUBTYPE_STRING],
[DB_COLUMN_TYPE_JSONB]: [FIELD_SUBTYPE_STRING],
- [DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ]: [FIELD_SUBTYPE_TIMESTAMP],
} as const;
diff --git a/server/typescript/packages/metadata/src/persistence/db/db-definition.embedded.ts b/server/typescript/packages/metadata/src/persistence/db/db-definition.embedded.ts
index e45bb1e90..c0eca0268 100644
--- a/server/typescript/packages/metadata/src/persistence/db/db-definition.embedded.ts
+++ b/server/typescript/packages/metadata/src/persistence/db/db-definition.embedded.ts
@@ -37,10 +37,23 @@ export const DB_DEFINITION: ProviderDefinition = {
"max": 1,
"allowedValues": [
"uuid",
- "jsonb",
- "timestamp_with_tz"
+ "jsonb"
],
- "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)."
+ "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2."
+ }
+ ]
+ },
+ {
+ "type": "field",
+ "subType": "timestamp",
+ "children": [
+ {
+ "type": "attr",
+ "subType": "boolean",
+ "name": "localTime",
+ "min": 0,
+ "max": 1,
+ "description": "When true, the timestamp is a naive wall-clock value with no timezone (Postgres `timestamp without time zone`); absent/false (the default) = an absolute instant (`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz escape hatch."
}
]
},
diff --git a/server/typescript/packages/metadata/test/attr-schema-validate.test.ts b/server/typescript/packages/metadata/test/attr-schema-validate.test.ts
index c1ed7f959..6d4701b0d 100644
--- a/server/typescript/packages/metadata/test/attr-schema-validate.test.ts
+++ b/server/typescript/packages/metadata/test/attr-schema-validate.test.ts
@@ -539,22 +539,23 @@ describe("attr-schema validation — @dbColumnType (subtype × value) pairing (R
expect(errors).toHaveLength(0);
});
- it("accepts @dbColumnType:timestamp_with_tz on field.timestamp", async () => {
+ it("accepts @localTime:true on field.timestamp (ADR-0036 Wave 2 naive opt-out)", async () => {
const { errors } = await load(
- entityWith({ "field.timestamp": { name: "recordedAt", "@dbColumnType": "timestamp_with_tz" } }),
+ entityWith({ "field.timestamp": { name: "wallClockAt", "@localTime": true } }),
);
expect(errors).toHaveLength(0);
});
- it("rejects @dbColumnType:timestamp_with_tz on field.string (illegal pairing) → ERR_BAD_ATTR_VALUE", async () => {
+ it("rejects the retired @dbColumnType:timestamp_with_tz value (anywhere) → ERR_BAD_ATTR_VALUE", async () => {
+ // ADR-0036 Wave 2 retired timestamp_with_tz entirely; it is now an
+ // unrecognized @dbColumnType value (not in the legal set { uuid, jsonb }).
const { errors } = await load(
- entityWith({ "field.string": { name: "recordedAt", "@dbColumnType": "timestamp_with_tz" } }),
+ entityWith({ "field.timestamp": { name: "recordedAt", "@dbColumnType": "timestamp_with_tz" } }),
);
const bad = errors.filter((e) => (e as { code?: string }).code === "ERR_BAD_ATTR_VALUE");
expect(bad).toHaveLength(1);
expect(bad[0]!.message).toContain("dbColumnType");
expect(bad[0]!.message).toContain("timestamp_with_tz");
- expect(bad[0]!.message).toContain("field.string");
});
it("rejects @dbColumnType:uuid on field.timestamp (illegal pairing) → ERR_BAD_ATTR_VALUE", async () => {
diff --git a/server/typescript/packages/metadata/test/db-definition-completeness.test.ts b/server/typescript/packages/metadata/test/db-definition-completeness.test.ts
index d9a1d293b..4b26ddd99 100644
--- a/server/typescript/packages/metadata/test/db-definition-completeness.test.ts
+++ b/server/typescript/packages/metadata/test/db-definition-completeness.test.ts
@@ -78,8 +78,16 @@ describe("db provider (data-driven) — attr placement", () => {
}
});
- test("@dbColumnType description renders the legal-value list", () => {
+ test("@dbColumnType description renders the legal-value list (uuid | jsonb — timestamp_with_tz retired)", () => {
const attr = registry.find(TYPE_FIELD, "string")!.attributes.find((a) => a.name === "dbColumnType")!;
- expect(attr.description).toContain("uuid | jsonb | timestamp_with_tz");
+ expect(attr.description).toContain("uuid | jsonb");
+ });
+
+ test("@localTime is on field.timestamp ONLY (ADR-0036 Wave 2)", () => {
+ expect(attrNames(TYPE_FIELD, FIELD_SUBTYPE_TIMESTAMP)).toContain("localTime");
+ for (const subType of FIELD_SUBTYPES) {
+ if (subType === FIELD_SUBTYPE_TIMESTAMP) continue;
+ expect(attrNames(TYPE_FIELD, subType)).not.toContain("localTime");
+ }
});
});
diff --git a/server/typescript/packages/metadata/test/field-definition-completeness.test.ts b/server/typescript/packages/metadata/test/field-definition-completeness.test.ts
index 9ae6d0a73..a76ebff5b 100644
--- a/server/typescript/packages/metadata/test/field-definition-completeness.test.ts
+++ b/server/typescript/packages/metadata/test/field-definition-completeness.test.ts
@@ -109,6 +109,10 @@ const STORAGE_EXTRA: Record = {
const AUTO_SET_EXTRA: Record = {
autoSet: { valueType: "string", required: false },
};
+// ADR-0036 Wave 2 — @localTime (the naive opt-out) on field.timestamp ONLY.
+const LOCAL_TIME_EXTRA: Record = {
+ localTime: { valueType: "boolean", required: false },
+};
const ENUM_PROMPT_EXTRA: Record = {
enumAlias: { valueType: "properties", required: false },
enumDoc: { valueType: "properties", required: false },
@@ -150,6 +154,7 @@ function expectedAttrsFor(subType: string): Record {
if (subType === FIELD_SUBTYPE_CURRENCY) Object.assign(exp, CURRENCY_EXTRA);
if (subType === FIELD_SUBTYPE_ENUM) Object.assign(exp, ENUM_CORE_EXTRA, ENUM_PROMPT_EXTRA);
if (TEMPORAL.has(subType)) Object.assign(exp, AUTO_SET_EXTRA);
+ if (subType === FIELD_SUBTYPE_TIMESTAMP) Object.assign(exp, LOCAL_TIME_EXTRA);
return exp;
}
diff --git a/server/typescript/packages/migrate-ts/src/expected-schema.ts b/server/typescript/packages/migrate-ts/src/expected-schema.ts
index fd5548988..4e8eb0401 100644
--- a/server/typescript/packages/migrate-ts/src/expected-schema.ts
+++ b/server/typescript/packages/migrate-ts/src/expected-schema.ts
@@ -47,7 +47,7 @@ import {
FIELD_ATTR_DB_COLUMN_TYPE,
DB_COLUMN_TYPE_UUID,
DB_COLUMN_TYPE_JSONB,
- DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ,
+ FIELD_ATTR_LOCAL_TIME,
STORAGE_FLATTENED,
DOC_ATTR_DESCRIPTION,
applyColumnNamingStrategy, DEFAULT_COLUMN_NAMING_STRATEGY,
@@ -729,13 +729,14 @@ function subtypeToSqlType(field: MetaData): SqlType {
// (subtype × value) pairing, so an unrecognized value never reaches here).
// dbColumnType slim-and-derive Phase 1: the array overrides (uuid_array /
// text_array) are RETIRED — native text[]/uuid[] are derived from `isArray`
- // below, not declared here.
+ // below, not declared here. ADR-0036 Wave 2: `timestamp_with_tz` is RETIRED
+ // too — field.timestamp is tz-aware by default + @localTime opts into naive
+ // (handled in the subtype switch below), not via this physical escape hatch.
const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
if (typeof dbColumnType === "string") {
switch (dbColumnType) {
- case DB_COLUMN_TYPE_UUID: return { kind: "uuid" };
- case DB_COLUMN_TYPE_JSONB: return { kind: "json" };
- case DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ: return { kind: "timestamp", withTimezone: true };
+ case DB_COLUMN_TYPE_UUID: return { kind: "uuid" };
+ case DB_COLUMN_TYPE_JSONB: return { kind: "json" };
}
}
@@ -777,7 +778,10 @@ function subtypeToSqlType(field: MetaData): SqlType {
case FIELD_SUBTYPE_BOOLEAN: return { kind: "boolean" };
case FIELD_SUBTYPE_DATE: return { kind: "date" };
case FIELD_SUBTYPE_TIME: return { kind: "time" }; // Postgres native TIME (whole-second wire form)
- case FIELD_SUBTYPE_TIMESTAMP: return { kind: "timestamp", withTimezone: false };
+ case FIELD_SUBTYPE_TIMESTAMP:
+ // ADR-0036 Wave 2: instant / tz-aware BY DEFAULT (→ timestamptz). A naive
+ // wall-clock value opts out with @localTime:true (→ TIMESTAMP, no tz).
+ return { kind: "timestamp", withTimezone: field.ownAttr(FIELD_ATTR_LOCAL_TIME) !== true };
case FIELD_SUBTYPE_OBJECT:
case FIELD_SUBTYPE_MAP: return { kind: "json" }; // field.map → single jsonb (pg) / text-json (sqlite) column
case FIELD_SUBTYPE_UUID: return { kind: "uuid" }; // R6 Plan 2a — Postgres native uuid
diff --git a/server/typescript/packages/migrate-ts/test/integration/sqlite-roundtrip.test.ts b/server/typescript/packages/migrate-ts/test/integration/sqlite-roundtrip.test.ts
index d1402e00a..d5a501b4f 100644
--- a/server/typescript/packages/migrate-ts/test/integration/sqlite-roundtrip.test.ts
+++ b/server/typescript/packages/migrate-ts/test/integration/sqlite-roundtrip.test.ts
@@ -59,7 +59,12 @@ async function applyRaw(kysely: Kysely>, sqlText: string
describe("SQLite round-trip — trainer-website fixture", () => {
test("create from empty: apply → re-diff yields no changes", async () => {
const metadata = await loadFixture("trainer-website-entities");
- const expected = buildExpectedSchema(metadata);
+ // dialect:"sqlite" normalizes SqlTypes to what SQLite introspection sees
+ // (boolean→integer, timestamp/date/time→text, etc.). Required since ADR-0036
+ // Wave 2 made field.timestamp tz-aware by default: SQLite has no tz concept,
+ // so without this the expected `timestamp{withTimezone:true}` could never
+ // equal the introspected `timestamp{withTimezone:false}`.
+ const expected = buildExpectedSchema(metadata, { dialect: "sqlite" });
const actual0 = await introspectSqlite(k);
const initial = await diff(expected, actual0);
expect(initial.blocked).toEqual([]);
@@ -82,7 +87,7 @@ describe("SQLite round-trip — trainer-website fixture", () => {
test("add a field → migration applies → re-diff yields no changes", async () => {
const metadata1 = await loadFixture("trainer-website-entities");
- const expected1 = buildExpectedSchema(metadata1);
+ const expected1 = buildExpectedSchema(metadata1, { dialect: "sqlite" });
{
const initial = await diff(expected1, await introspectSqlite(k));
const { up } = emit(initial.changes, { dialect: "sqlite", expectedSchema: expected1 });
@@ -98,7 +103,7 @@ describe("SQLite round-trip — trainer-website fixture", () => {
)["object.entity"];
subscriber.children.push({ "field.string": { name: "phone" } });
const metadata2 = (await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(json))])).root;
- const expected2 = buildExpectedSchema(metadata2);
+ const expected2 = buildExpectedSchema(metadata2, { dialect: "sqlite" });
const second = await diff(expected2, await introspectSqlite(k));
expect(second.blocked).toEqual([]);
diff --git a/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts b/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts
index 2b015a602..4b52f8fef 100644
--- a/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts
+++ b/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts
@@ -464,7 +464,7 @@ describe("buildExpectedSchema — @dbColumnType physical override (R6 Plan 2b)",
expect(cols.find((c) => c.name === "tools")?.sqlType).toEqual({ kind: "json" });
});
- test("@dbColumnType:timestamp_with_tz on field.timestamp → SqlType.timestamp{withTimezone:true}", async () => {
+ test("a plain field.timestamp is instant / tz-aware by default → SqlType.timestamp{withTimezone:true} (ADR-0036 Wave 2)", async () => {
const root = await loadInline([
{
"object.entity": {
@@ -472,7 +472,7 @@ describe("buildExpectedSchema — @dbColumnType physical override (R6 Plan 2b)",
children: [
{ "source.rdb": { "@table": "a" } },
{ "field.long": { name: "id" } },
- { "field.timestamp": { name: "recordedAt", "@dbColumnType": "timestamp_with_tz" } },
+ { "field.timestamp": { name: "recordedAt" } },
{ "identity.primary": { "name": "id", "@fields": "id" } },
],
},
@@ -483,7 +483,7 @@ describe("buildExpectedSchema — @dbColumnType physical override (R6 Plan 2b)",
expect(col?.sqlType).toEqual({ kind: "timestamp", withTimezone: true });
});
- test("a plain field.timestamp (no @dbColumnType) stays withTimezone:false", async () => {
+ test("field.timestamp @localTime:true opts into naive → withTimezone:false", async () => {
const root = await loadInline([
{
"object.entity": {
@@ -491,7 +491,7 @@ describe("buildExpectedSchema — @dbColumnType physical override (R6 Plan 2b)",
children: [
{ "source.rdb": { "@table": "a" } },
{ "field.long": { name: "id" } },
- { "field.timestamp": { name: "createdAt" } },
+ { "field.timestamp": { name: "createdAt", "@localTime": true } },
{ "identity.primary": { "name": "id", "@fields": "id" } },
],
},
diff --git a/spec/decisions/ADR-0019-runtime-return-type-contract.md b/spec/decisions/ADR-0019-runtime-return-type-contract.md
index e537b388f..e8ac21ec0 100644
--- a/spec/decisions/ADR-0019-runtime-return-type-contract.md
+++ b/spec/decisions/ADR-0019-runtime-return-type-contract.md
@@ -24,7 +24,7 @@ Per-concept native return type:
| `field.long` | native 64-bit integer (TS: `number`; the BIGINT-as-number caveat for values > 2^53 is documented per-port) |
| `field.decimal` | native **exact** decimal — Java/Kotlin `BigDecimal`, C# `decimal`, Python `Decimal`; **TS `string`** (TS has no native exact-decimal type; `string` preserves precision — see the SP-A type-fidelity design) |
| `field.double` / `field.float` | native float/double |
-| `field.timestamp` / `date` / `time` | native temporal type; a **timezone-aware** value denotes `TIMESTAMPTZ`, a **naive** value denotes `TIMESTAMP` (this is how the boundary canonicalizer distinguishes them without column OIDs) |
+| `field.timestamp` / `date` / `time` | native temporal type. **`field.timestamp` is instant / timezone-aware BY DEFAULT (ADR-0036 Wave 2)** → `TIMESTAMPTZ`; native `Instant` (Java/Kotlin) / `DateTimeOffset` (C#) / aware `datetime` (Python) / ISO-8601 string with `Z` (TS). **`@localTime:true` is the naive opt-out** → `TIMESTAMP`; native `LocalDateTime` / `DateTime(Unspecified)` / naive `datetime` / string. The boundary canonicalizer still distinguishes the two by the value's tz-awareness (a **timezone-aware** value → `TIMESTAMPTZ`, a **naive** value → `TIMESTAMP`, without column OIDs) — only the DEFAULT moved from naive to tz-aware. |
| `field.uuid` | native uuid type, or string where idiomatic for the port |
| `field.string` / `field.enum` | native string |
| `field.object` (structured jsonb) | native map / dict / object |
diff --git a/spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md b/spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md
index d070d46a3..f8e89c858 100644
--- a/spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md
+++ b/spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md
@@ -23,15 +23,15 @@ The throughline (confirmed by a cross-framework study of 11 ORMs/IDLs): **base t
## Decision
-### 1. Timestamp → instant-by-default, as a subtype split
+### 1. Timestamp → instant-by-default, naive via the `@localTime` attribute
-`@dbColumnType: timestamp_with_tz` fails the physical test — timezone-awareness changes the native type in every port (`Instant`/`DateTimeOffset`/aware `datetime` vs `LocalDateTime`/`DateTime`/naive). So model it as distinct subtypes, with **instant as the default**:
+`@dbColumnType: timestamp_with_tz` fails the physical test — timezone-awareness changes the native type in every port (`Instant`/`DateTimeOffset`/aware `datetime` vs `LocalDateTime`/`DateTime`/naive). It is logical vocabulary, not a physical knob. But it is the **same kind** of value (a date+time) with one orthogonal property (does it carry a zone), so by [ADR-0037](ADR-0037-metamodel-vocabulary-expansion-decision-framework.md) it is an **attribute, not a subtype**:
- **`field.timestamp` = instant / tz-aware** — the default and common case → `timestamptz`; native `Instant` (Java/Kotlin) / `DateTimeOffset` (C#) / aware `datetime` (Python) / ISO-8601 string with `Z` (TS).
-- **`field.localDateTime` = naive wall-clock** — the narrow exception → `timestamp without time zone`; native `LocalDateTime` / `DateTime(Unspecified)` / naive `datetime` / string.
-- **`@dbColumnType: timestamp_with_tz` is retired** — the ~180 adopter annotations simply drop (a bare `field.timestamp` is now what they wanted); only genuinely-naive fields become `field.localDateTime` (many of those are latent bugs the flip fixes).
+- **`@localTime: true`** on `field.timestamp` = naive wall-clock — the narrow exception → `timestamp without time zone`; native `LocalDateTime` / `DateTime(Unspecified)` / naive `datetime` / string. A boolean attribute, default false/absent (so the common case needs nothing).
+- **`@dbColumnType: timestamp_with_tz` is retired** — the ~180 adopter annotations simply drop (a bare `field.timestamp` is now what they wanted); only genuinely-naive fields gain `@localTime: true` (many bare timestamps that were naive-by-accident are latent bugs the flip fixes).
-Rationale: the type-system/IDL layer (Protobuf, GraphQL, Ecto, and the Java/.NET/Python native types the ports already bind to) models this as a *distinct type*, not a flag; the modifier-flag form is the SQL-ORM-builder idiom. Aware-by-default is the unanimous best-practice (Postgres "Don't Do This", Ecto, Django 5.0). `timetz` is discouraged and `date` is inherently naive, so the split applies only to `timestamp` — no family explosion. Subtype over attribute because the adopter migration cost is identical either way (so decide on merit) and the subtype gives cleaner per-port native binding.
+Rationale: aware-by-default is the unanimous best-practice (Postgres "Don't Do This", Ecto, Django 5.0). `timetz` is discouraged and `date` is inherently naive, so the distinction applies only to `timestamp`. The attribute is the right shape (not a subtype) because timezone-awareness is an orthogonal modifier of one base type — the same shape as `@maxLength` on a string — not a different *kind* of value the way `field.uuid` is (ADR-0037, step 2). _Earlier drafts proposed a `field.localDateTime` subtype; that was corrected to the attribute under the ADR-0037 framework — the per-port native-binding conditional already exists, and a subtype would over-weight a rare modifier into a sibling type._
### 2. Semantic string `@format` — a closed-set attribute on `field.string`
@@ -56,7 +56,7 @@ The earlier "slim-and-derive" landed the *derive* half (native `text[]`/`uuid[]`
- Decisions 1 and 4 are **breaking** and must land inside the consolidation window; 2, 3, 5 are additive (3 adds a build-time error only for already-ambiguous models).
- Each ships cross-port with a `registry-conformance` fixture (ADR-0023 strict provenance).
-- Adopters migrate once, to the final shapes: drop ~180 `timestamp_with_tz` annotations, convert genuinely-naive timestamps to `field.localDateTime`, move 5 array fields to `isArray`, and name same-pair relationships. `@dbColumnType:uuid` and `@dbColumnType:jsonb` continue working unchanged.
+- Adopters migrate once, to the final shapes: drop ~180 `timestamp_with_tz` annotations, add `@localTime: true` to genuinely-naive timestamps, move 5 array fields to `isArray`, and name same-pair relationships. `@dbColumnType:uuid` and `@dbColumnType:jsonb` continue working unchanged.
## Deferred to a later wave (recorded so they are reservations, not gaps)
diff --git a/spec/metamodel/db.json b/spec/metamodel/db.json
index d874ff512..a0675f689 100644
--- a/spec/metamodel/db.json
+++ b/spec/metamodel/db.json
@@ -7,7 +7,14 @@
"children": [
{ "type": "attr", "subType": "string", "name": "column", "min": 0, "max": 1, "description": "Physical column name for this field on an rdb source. Defaults to the field name via columnNamingStrategy." },
{ "type": "attr", "subType": "boolean", "name": "db.indexed", "min": 0, "max": 1, "description": "When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means)." },
- { "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb", "timestamp_with_tz"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb | timestamp_with_tz, each legal only on a specific logical field subtype (uuid/jsonb on field.string, timestamp_with_tz on field.timestamp). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1)." }
+ { "type": "attr", "subType": "string", "name": "dbColumnType", "min": 0, "max": 1, "allowedValues": ["uuid", "jsonb"], "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2." }
+ ]
+ },
+ {
+ "type": "field",
+ "subType": "timestamp",
+ "children": [
+ { "type": "attr", "subType": "boolean", "name": "localTime", "min": 0, "max": 1, "description": "When true, the timestamp is a naive wall-clock value with no timezone (Postgres `timestamp without time zone`); absent/false (the default) = an absolute instant (`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz escape hatch." }
]
},
{