Skip to content

Commit abddef7

Browse files
dmealingclaude
andcommitted
fix(loader): the bare-object error names the ladder, not just the escape hatch
ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF fires at the exact moment an author writes a shape they have not declared — and until now it answered with one alternative, the open bag: "For an open/untyped JSON map use @dbColumnType: jsonb on a field.string instead of a bare object." That is the last rung of a three-rung ladder presented as the only rung, at the one moment the author is most likely to take whatever the tool suggests. The authoring skill already teaches the full ladder and says so explicitly ("the loader's pointer at @dbColumnType: jsonb on that error is for the genuinely open case, not the missing-VO case"); the loader itself did not. All four ports now say the same thing, in each port's own quoting style: a known key set → declare an object.value and @objectref it dynamic keys, known value → field.map (@objectref or @valueType) a bag no reader pins a key in → @dbColumnType: jsonb on a field.string The rule and the code are unchanged — a bare field.object is still rejected, with the same code and the same source envelope. Only the guidance changed. expected-errors.json gates code + jsonPath, so no fixture churn; verified no port test asserts the old substring (the two migrate-ts integration fixtures that cited it in a COMMENT are corrected — both are legitimately on the last rung, which is now said rather than implied). docs/features/field-types.md listed 11 field subtypes where the registry registers 17. The six missing rows (float, decimal, time, uri, inet, map) are added with per-port native types read from each port's own mapper rather than inferred, plus a footnote for the three rows that need one: the uri/inet @lenient degrade, and field.map's real port coverage — Java's SpringTypeMapper has no MapField arm, C# emits the property but DbContextGenerator writes no EF storage mapping, and no persistence- or api-contract fixture exercises it on any port. Verified: TS metadata 2621/0; Python unit+conformance 947/0; C# MetaObjects builds clean; Java metadata module compiles; scripts/ci-local.sh --quick passed (leak-scan, fixture-lint, ts build+typecheck, conformance: typescript, ts unit suites, doc-template drift, embedded-library drift all green). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent 5096889 commit abddef7

9 files changed

Lines changed: 83 additions & 23 deletions

File tree

docs/features/field-types.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,40 @@ across ports — a `field.currency` is integer minor units everywhere; a
1414
| `field.int` | `number` | `Integer` | `Int` | `int` | `int` | `integer` |
1515
| `field.long` | `number` (or bigint) | `Long` | `Long` | `long` | `int` | `bigint` |
1616
| `field.double` | `number` | `Double` | `Double` | `double` | `float` | `double precision` |
17+
| `field.float` | `number` | `Float` | `Float` | `float` | `float` | `real` |
18+
| `field.decimal` | `string` | `BigDecimal` | `BigDecimal` | `decimal` | `Decimal` | `numeric(@precision, @scale)` |
1719
| `field.boolean` | `boolean` | `Boolean` | `Boolean` | `bool` | `bool` | `boolean` |
1820
| `field.date` | `Date` | `LocalDate` | `LocalDate` | `DateOnly` | `date` | `date` |
21+
| `field.time` | `string` | `LocalTime` | `LocalTime` | `TimeOnly` | `datetime.time` | `time` |
1922
| `field.timestamp` | `Date` | `Instant` | `Instant` | `DateTimeOffset` | `datetime` | `timestamp with time zone` |
2023
| `field.currency` | `number` (minor units) | `Long` (minor units) | `Long` (minor units) | `long` (minor units) | `int` (minor units) | `bigint` |
2124
| `field.uuid` | `string` | `UUID` | `UUID` | `Guid` | `UUID` | `uuid` |
25+
| `field.uri` | `string` | `java.net.URI` | `java.net.URI` | `Uri` | `AnyUrl` | `text` |
26+
| `field.inet` | `string` | `InetAddress` | `InetAddress` | `IPAddress` | `IPvAnyAddress` | `inet` |
2227
| `field.enum` | union + `z.enum` | `Enum` | `enum class` | `enum` | `Enum` | `varchar` + `CHECK` |
2328
| `field.object` | nested type | nested class | nested data class | nested record | nested dataclass | per `@storage` |
29+
| `field.map` | `Record<string, V>` | — (see below) | `Map<String, V>` | `Dictionary<string, V>` | `dict[str, V]` | `jsonb` |
30+
31+
That is the whole registered vocabulary — 17 concrete subtypes. `field.base` is an
32+
abstract registry anchor, never authored (`ERR_ABSTRACT_SUBTYPE_AUTHORED`).
33+
34+
Three rows need a footnote:
35+
36+
- **`field.uri` / `field.inet`** bind a native URL/IP type on every port but TypeScript,
37+
which has none (the same reason `field.uuid` is a `string` there). `@lenient: true`
38+
degrades both to a plain string with no validator, and takes the `inet` column down to
39+
`text`.
40+
- **`field.map`** is the typed dict: string keys, and a value type set by exactly one of
41+
`@valueType` (a scalar subtype) or `@objectRef` (a value object) — `V` above. It is one
42+
jsonb column holding the JSON object; `isArray` does not apply. **Java does not complete
43+
this rung:** `SpringTypeMapper.javaTypeName` has no `MapField` arm, so a mapped field on
44+
a Spring entity reaches its `unsupported Spring DTO type mapping` throw. On C# the
45+
property and its `[Column]` annotation are emitted but `DbContextGenerator` writes no EF
46+
storage mapping for the dictionary, so EF does not persist it. **No persistence- or
47+
api-contract-conformance fixture exercises `field.map` on any port — it is loader-gated
48+
only.** Until that closes, a stable key set is better declared as an `object.value`
49+
behind `field.object`; see [ADR-0037](../../spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md)
50+
for which of the two a shape belongs in.
2451

2552
## Common field attributes
2653

fixtures/conformance/ERROR-CODES.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"ERR_BAD_ATTR_FILTER": "An attr.filter references a field that is not addressable in that context or uses a disallowed op: a dataGrid @filter over a non-filterable field or an op disallowed for the field's subtype, or a projection row-scope @filter over a non-declared (dangling) or aggregate-derived field (#207).",
4949
"ERR_STORAGE_FLATTENED_ARRAY": "@storage \"flattened\" cannot be combined with isArray=true.",
5050
"ERR_STORAGE_WITHOUT_OBJECT_REF": "@storage was set on a field that has no @objectRef.",
51-
"ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF": "ADR-0013: a field.object declares no @objectRef. A field.object models a typed nested value and REQUIRES @objectRef. For a genuinely open/untyped JSON map, use the physical escape hatch @dbColumnType: jsonb on a field.string instead of a bare object.",
51+
"ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF": "ADR-0013: a field.object declares no @objectRef. A field.object models a typed nested value and REQUIRES @objectRef. The message states the whole ladder rather than only the escape hatch: a known key set is an object.value the field @objectRefs; dynamic keys over a known value type are a field.map (@objectRef for a value object, @valueType for a scalar); only a bag no reader pins a key in is @dbColumnType: jsonb on a field.string. Naming the escape hatch alone is what taught adopters to reach for the bag by default.",
5252
"ERR_UNRESOLVED_OBJECT_REF": "ADR-0042: a field.object / field.map @objectRef does not resolve to any object in the loaded tree (a dangling target). The ref resolves package-locally when bare (referrer's package, else root-level) and exactly when FQN — a bare cross-package ref no longer binds elsewhere. The error names same-short-name objects in other packages so the author can qualify it.",
5353
"ERR_RESERVED_ATTR": "An @-prefixed reserved structural keyword (e.g. @name, @isArray, @children) was used as an inline attribute.",
5454
"ERR_SCOPE_PATTERN_INVALID": "Phase-1 metadata-source-resolution: a scope include/exclude package pattern is malformed (empty pattern or empty :: segment).",

server/csharp/MetaObjects/Loader/ValidationPasses.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2919,8 +2919,12 @@ private static void WalkDbColumnType(MetaData node, List<MetaError> errors)
29192919
// Cross-attribute validation for field.object + @storage (ADR-0013):
29202920
// - A field.object ALWAYS requires @objectRef → ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF.
29212921
// A field.object models a typed nested value; without @objectRef it is
2922-
// "an oxymoron at the logical layer". Open/untyped JSON uses the physical
2923-
// @dbColumnType: jsonb escape hatch on field.string, NOT a bare object.
2922+
// "an oxymoron at the logical layer". The MESSAGE states the whole ladder,
2923+
// not just the last rung: a known key set is an object.value the field
2924+
// @objectRefs; dynamic keys over a known value type are a field.map; only
2925+
// a bag no reader pins a key in is @dbColumnType: jsonb on a field.string.
2926+
// Naming the escape hatch alone — which this error did until now — is what
2927+
// taught adopters to reach for the bag by default.
29242928
// This rule subsumes the legacy @storage-without-@objectRef check
29252929
// (@storage is only meaningful on a field.object), so missing-@objectRef
29262930
// now always reports this single, clearer error — one error per node
@@ -2951,8 +2955,12 @@ public static IReadOnlyList<MetaError> ValidateFieldObjectStorage(MetaData root)
29512955
{
29522956
errors.Add(new MetaError(
29532957
$"field.object \"{obj.Name}.{field.Name}\" has no @objectRef; " +
2954-
"a field.object requires @objectRef. For an open/untyped JSON map " +
2955-
"use @dbColumnType: jsonb on a field.string instead of a bare object.",
2958+
"a field.object models a typed nested value and requires one. " +
2959+
"Take the first of these that fits: a known key set → declare an " +
2960+
"object.value and point @objectRef at it; dynamic keys over a known value " +
2961+
"type → field.map with @objectRef (a value object) or @valueType (a " +
2962+
"scalar); a genuinely open bag no reader pins a key in → " +
2963+
"@dbColumnType: jsonb on a field.string.",
29562964
ErrorCode.ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF,
29572965
Envelope: field.Source));
29582966
continue;

server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,8 +1893,12 @@ private static List<String> junctionReferenceFkFields(MetaObject junction) {
18931893
// Rules, matching the cross-port spec (ADR-0013):
18941894
// 1. A field.object ALWAYS requires @objectRef → ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF.
18951895
// A field.object models a typed nested value; without @objectRef it is "an
1896-
// oxymoron at the logical layer". Open/untyped JSON uses the physical
1897-
// @dbColumnType: jsonb escape hatch on field.string, NOT a bare object.
1896+
// oxymoron at the logical layer". The MESSAGE states the whole ladder, not
1897+
// just the last rung: a known key set is an object.value the field
1898+
// @objectRefs; dynamic keys over a known value type are a field.map; only a
1899+
// bag no reader pins a key in is @dbColumnType: jsonb on a field.string.
1900+
// Naming the escape hatch alone — which this error did until now — is what
1901+
// taught adopters to reach for the bag by default.
18981902
// This rule subsumes the legacy @storage-without-@objectRef check (@storage
18991903
// is only meaningful on a field.object), so missing-@objectRef now always
19001904
// reports this single, clearer error — one error per node (we skip the
@@ -1931,9 +1935,12 @@ private static void validateObjectFieldStorageNode(MetaData node) {
19311935
throw new MetaDataException(
19321936
ErrorMessageConstants.ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF
19331937
+ ": field.object '" + field.getName()
1934-
+ "' has no @objectRef — a field.object requires @objectRef."
1935-
+ " For an open/untyped JSON map use @dbColumnType: jsonb on a"
1936-
+ " field.string instead of a bare object.",
1938+
+ "' has no @objectRef — a field.object models a typed nested value"
1939+
+ " and requires one. Take the first of these that fits: a known key set"
1940+
+ " → declare an object.value and point @objectRef at it; dynamic keys"
1941+
+ " over a known value type → field.map with @objectRef (a value object)"
1942+
+ " or @valueType (a scalar); a genuinely open bag no reader pins a key"
1943+
+ " in → @dbColumnType: jsonb on a field.string.",
19371944
ErrorCode.ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF, field.getSource());
19381945
}
19391946

server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,11 @@ private ErrorMessageConstants() {
186186

187187
/**
188188
* Error code emitted when a {@code field.object} declares no {@code @objectRef}
189-
* (ADR-0013). A field.object models a typed nested value and REQUIRES @objectRef;
190-
* an open/untyped JSON map uses the physical {@code @dbColumnType: jsonb} escape
191-
* hatch on a {@code field.string} instead of a bare object.
189+
* (ADR-0013). A field.object models a typed nested value and REQUIRES @objectRef.
190+
* The message states the whole ladder rather than only the escape hatch: a known
191+
* key set is an {@code object.value} the field {@code @objectRef}s; dynamic keys
192+
* over a known value type are a {@code field.map}; only a bag no reader pins a key
193+
* in is {@code @dbColumnType: jsonb} on a {@code field.string}.
192194
* Cross-language contract: {@code ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF}.
193195
*/
194196
public static final String ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF = "ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF";

server/python/src/metaobjects/loader/validation_passes.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3365,8 +3365,12 @@ def _validate_sortable_has_supported_subtype(
33653365
# Cross-port rules (ADR-0013):
33663366
# 1. A field.object ALWAYS requires @objectRef → ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF.
33673367
# A field.object models a typed nested value; without @objectRef it is an
3368-
# oxymoron at the logical layer. Open/untyped JSON uses the physical
3369-
# @dbColumnType: jsonb escape hatch on field.string, NOT a bare object. This
3368+
# oxymoron at the logical layer. The MESSAGE states the whole ladder, not just
3369+
# the last rung: a known key set is an object.value the field @objectRefs;
3370+
# dynamic keys over a known value type are a field.map; only a bag no reader
3371+
# pins a key in is @dbColumnType: jsonb on a field.string. Naming the escape
3372+
# hatch alone — which this error did until now — is what taught adopters to
3373+
# reach for the bag by default. This
33703374
# rule subsumes the legacy @storage-without-@objectRef check (@storage is only
33713375
# meaningful on a field.object), so missing-@objectRef now always reports this
33723376
# single, clearer error — one error per node (the flattened/array check is
@@ -3388,8 +3392,12 @@ def _validate_field_object_storage(root: MetaData, errors: list[MetaError]) -> N
33883392
code=ErrorCode.ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF,
33893393
message=(
33903394
f"field.object '{node.name}' has no @objectRef — a field.object "
3391-
f"requires @objectRef. For an open/untyped JSON map use "
3392-
f"@dbColumnType: jsonb on a field.string instead of a bare object."
3395+
f"models a typed nested value and requires one. Take the first of "
3396+
f"these that fits: a known key set → declare an object.value and point "
3397+
f"@objectRef at it; dynamic keys over a known value type → field.map "
3398+
f"with @objectRef (a value object) or @valueType (a scalar); a "
3399+
f"genuinely open bag no reader pins a key in → @dbColumnType: jsonb "
3400+
f"on a field.string."
33933401
),
33943402
envelope=node.source,
33953403
))

server/typescript/packages/metadata/src/loader/validation-passes.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1672,8 +1672,13 @@ export function validateDerivedFieldProvidability(root: MetaData): ParseError[]
16721672
// Rules (ADR-0013):
16731673
// 1. A field.object ALWAYS requires @objectRef. A field.object models a typed
16741674
// nested value; without @objectRef it is "an oxymoron at the logical layer".
1675-
// Genuinely open/untyped JSON uses the physical @dbColumnType: jsonb escape
1676-
// hatch on field.string, NOT a bare object. → ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF.
1675+
// → ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF. The MESSAGE states the whole ladder,
1676+
// not just the last rung: a known key set is an object.value the field
1677+
// @objectRefs; dynamic keys over a known value type are a field.map; only a
1678+
// bag no reader pins a key in is @dbColumnType: jsonb on a field.string.
1679+
// Naming the escape hatch alone — which this error did until now — is what
1680+
// taught adopters to reach for the bag by default, and it is the mechanism
1681+
// behind every untyped-jsonb column an adoption audit finds.
16771682
// (This rule subsumes the legacy @storage-without-@objectRef check —
16781683
// @storage is only meaningful on a field.object, so the missing-@objectRef
16791684
// situation now always reports this single, clearer error. One error per
@@ -1699,7 +1704,7 @@ export function validateFieldObjectStorage(root: MetaData): ParseError[] {
16991704
// further @storage error on the same node would be redundant.
17001705
errors.push(
17011706
new ParseError(
1702-
`field.object "${obj.name}.${field.name}" has no @objectRef; a field.object requires @objectRef. For an open/untyped JSON map use @dbColumnType: jsonb on a field.string instead of a bare object.`,
1707+
`field.object "${obj.name}.${field.name}" has no @objectRef; a field.object models a typed nested value and requires one. Take the first of these that fits: a known key set → declare an object.value and point @objectRef at it; dynamic keys over a known value type → field.map with @objectRef (a value object) or @valueType (a scalar); a genuinely open bag no reader pins a key in → @dbColumnType: jsonb on a field.string.`,
17031708
{ code: "ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF", source: field.source },
17041709
),
17051710
);

server/typescript/packages/migrate-ts/test/integration/pg-expression-index-casts.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ const meta = (indexes: ReadonlyArray<{ name: string; expr: string }>): string =>
6363
children: [
6464
{ "source.rdb": { "@table": "ledger" } },
6565
{ "field.long": { name: "id" } },
66-
// An open/untyped JSON map, spelled the way the loader's own error prescribes.
66+
// A genuinely open bag — the LAST rung of the jsonb ladder, and the right one
67+
// here: the fixture exercises expression-index casts over jsonb, pinning no key.
6768
{ "field.string": { name: "payload", "@column": "payload", "@dbColumnType": "jsonb" } },
6869
{ "field.decimal": { name: "amt", "@column": "amt", "@precision": 10, "@scale": 2 } },
6970
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },

server/typescript/packages/migrate-ts/test/integration/pg-expression-index-spacing.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ const meta = (expr: string): string =>
4848
children: [
4949
{ "source.rdb": { "@table": "notification" } },
5050
{ "field.long": { name: "id" } },
51-
// An open/untyped JSON map: a `field.string` with a jsonb physical type, which
52-
// is what the loader's own error message prescribes for a bare object.
51+
// A genuinely open bag — the LAST rung of the jsonb ladder, and the right one
52+
// here: this fixture exists to exercise expression-index rendering over a jsonb
53+
// column, so nothing in it pins a key. (A shape the code knows would be an
54+
// object.value or a field.map; the loader's error names all three rungs.)
5355
{ "field.string": { name: "requestContext", "@column": "request_context", "@dbColumnType": "jsonb" } },
5456
{ "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } },
5557
{ "index.lookup": { name: "byDevice", "@expr": expr } },

0 commit comments

Comments
 (0)