Summary
FR-036 Pin 1 — "a @required non-array string is non-empty" — is applied to every object.* in the Python port, including object.value. FR-036 was designed for the wire tier (object.entity POST/PATCH), and the TypeScript reference port excludes object.value by construction. The result is exactly the cross-port divergence FR-036 existed to remove — plus a Python-internal self-contradiction, and a vocabulary regression that leaves "required to be provided, may be empty" inexpressible.
Surfaced by a downstream Python adopter upgrading 0.15.x → 0.19.0: in-process prompt-payload object.value models (template-slot carriers, never an HTTP input, never persisted) began raising ValidationError on legitimately-empty slots, breaking 62 unit tests and a production code path. Their metadata is correct as documented — see §4.
1. The scope bug
server/python/src/metaobjects/codegen/generators/entity_model.py (Pin 1) keys only on subtype/array/required — there is no object-kind gate:
# FR-036 Pin 1 — a @required non-array string is non-empty: reject null / "" but# ACCEPT whitespace-only. Emit an implicit min_length of 1, keeping the stricter# floor if a validator.length @min already set one.if (
field.sub_type==fc.FIELD_SUBTYPE_STRINGandnotfield_is_array(field)
andfield.attrs().get(fc.FIELD_ATTR_REQUIRED) isTrue
):
existing_min=kwargs.get("min_length")
kwargs["min_length"] =max(1, existing_min) ifisinstance(existing_min, int) else1The default Python suite runs entity_model() over every object.* (server/python/src/metaobjects/cli.py), so object.value gets the constraint.
Four independent reasons this is out of designed scope:
- The FR-036 spec is entirely wire-tier. Its enforcement matrix is POST/PATCH-over-HTTP and its emitter section names entity DTOs. The strings
object.value and payload appear nowhere in it. - The conformance corpus models only
object.entity (fixtures/validation-conformance/meta.json) — so nothing locks the object.value behavior either way. - The TS reference port excludes it explicitly —
server/typescript/packages/codegen-ts/src/generators/api-model.ts:348:
returnobj.subType!==OBJECT_SUBTYPE_VALUE&&!isTphSubtype(obj);
object.value gets a plain interface with zero runtime validation. TS and Python therefore validate identical metadata differently. - ADR-0028 defines
object.value as a constructed shape — "values are never populated — they are constructed" — i.e. explicitly not a wire-input tier.
Python also contradicts itself. For the same object.value, the entity generator emits Field(min_length=1) while payload_vo_generator.py emits no constraints at all — two generated classes for one VO, disagreeing:
# <name>_prompt_payload.py (payload VO generator)goal: str# <Name>Payload.py (entity_model generator)goal: str=Field(min_length=1)
Repro
# meta.yamlmetadata:
package: appchildren:
- object.value:
name: Slotschildren:
- field.string: {name: goal, required: true}metaobjects gen ./meta --out ./out --generators entity
Observed (0.19.0):goal: str = Field(min_length=1) → Slots(goal="") raises ValidationError.
Expected: an object.value is not a wire input; TS emits no constraint for the same metadata.
2. Vocabulary regression — the opt-out is clamped shut
@required is documented as presence; Pin 1 redefined it as non-empty; and the natural escape hatch is deliberately foreclosed. validator.length @min: 0 on a required string cannot suppress the floor:
- Python:
kwargs["min_length"] = max(1, existing_min) (above) - TS mirror:
codegen-ts/src/templates/zod-validators.ts — comments that an explicit @min: 0"can't suppress it"
So "must be provided, but may be the empty string" is now inexpressible: dropping @required flips the generated type to T | None = None, losing presence typing entirely. Note non-emptiness already had a home — validator.length @min: 1 maps to exactly min_length=1. Pin 1 duplicated an expressible constraint into @required while removing the ability to not say it.
Aside on the whitespace rule. Pin 1 rejects "" but accepts " ". That isn't a semantic — a meaningful-content rule would trim, a presence rule would accept "". The spec states the origin plainly: "Matches TS, the reference impl." It's the shadow of z.string().min(1). Reasonable as a cross-port consistency ruling for the wire tier; the problem is that it silently redefined an attribute documented as presence, everywhere.
3. The documentation item was never executed
FR-036 §A5 explicitly ordered: "Reconcile the registry text: field @required says 'NOT NULL', validator.required says 'null/empty' — align both to the pinned non-empty semantic."
This did not happen. As shipped in v0.19.0:
spec/metamodel/field.json:13 — "When true, the field is NOT NULL. Equivalent to attaching a validator.required child."server/typescript/packages/metadata/src/core/field/field-definition.embedded.ts:45 — same textvalidator-definition.embedded.ts:38-39 — "Fails when the value is null/empty (NOT NULL)" / "A field must be present (NOT NULL)" (internally contradictory, pre-existing)- The
metaobjects-authoring skill never defines the non-empty semantic
The new meaning exists in prose only in the CHANGELOG breaking banner. An adopter reading the vocabulary today — post-change — would still author required: true to mean "must be present," which is precisely what happened.
4. Latent: the entity READ model also carries the constraint
In Python the constraint lands on the entity base/read model too, so reading an existing DB row containing "" throws at model construction. TS's Zod equivalent is insert/update-only. Not the reported breakage, but the same over-application one tier down — worth gating in the same pass.
Proposed fixes
- Scope fix (patch). Gate Pin 1 to
object.entity (exclude OBJECT_SUBTYPE_VALUE) in entity_model.py. Restores FR-036's designed scope, TS parity, ADR-0028 purity, and Python-internal coherence. Add an object.value case to fixtures/validation-conformance/ so it stays locked. - Restore expressibility (additive). Honor an explicit
validator.length @min: 0 on a required string as the opt-out — remove the max(1, …) clamp in Python and TS. An author writing @min: 0 is unambiguously saying "empty allowed," so this needs no further breaking change. (A full revert of Pin 1 to presence-only would be semantically cleaner but is a second breaking change against conformance-locked behavior — not proposed here.) - Execute A5 (mandatory either way). Update
spec/metamodel/field.json, field-definition.embedded.ts, validator-definition.embedded.ts, and the authoring skill to state: strings under @required reject "" (whitespace accepted), arrays do not, the entity-vs-value scope, and the opt-out. - Consider gating the read-model path (§4) in the same change.
Environment
- Python
metaobjects0.19.0; TS @metaobjectsdev/*0.19.0; metamodelVersion 0.9. - Introduced in 0.16.0 (FR-036). Adopters on
object.value payloads see it the moment they regenerate.
Summary
FR-036 Pin 1 — "a
@requirednon-array string is non-empty" — is applied to everyobject.*in the Python port, includingobject.value. FR-036 was designed for the wire tier (object.entityPOST/PATCH), and the TypeScript reference port excludesobject.valueby construction. The result is exactly the cross-port divergence FR-036 existed to remove — plus a Python-internal self-contradiction, and a vocabulary regression that leaves "required to be provided, may be empty" inexpressible.Surfaced by a downstream Python adopter upgrading 0.15.x → 0.19.0: in-process prompt-payload
object.valuemodels (template-slot carriers, never an HTTP input, never persisted) began raisingValidationErroron legitimately-empty slots, breaking 62 unit tests and a production code path. Their metadata is correct as documented — see §4.1. The scope bug
server/python/src/metaobjects/codegen/generators/entity_model.py(Pin 1) keys only on subtype/array/required — there is no object-kind gate:The default Python suite runs
entity_model()over everyobject.*(server/python/src/metaobjects/cli.py), soobject.valuegets the constraint.Four independent reasons this is out of designed scope:
object.valueandpayloadappear nowhere in it.object.entity(fixtures/validation-conformance/meta.json) — so nothing locks theobject.valuebehavior either way.server/typescript/packages/codegen-ts/src/generators/api-model.ts:348:object.valuegets a plain interface with zero runtime validation. TS and Python therefore validate identical metadata differently.object.valueas a constructed shape — "values are never populated — they are constructed" — i.e. explicitly not a wire-input tier.Python also contradicts itself. For the same
object.value, the entity generator emitsField(min_length=1)whilepayload_vo_generator.pyemits no constraints at all — two generated classes for one VO, disagreeing:Repro
Observed (0.19.0):
goal: str = Field(min_length=1)→Slots(goal="")raisesValidationError.Expected: an
object.valueis not a wire input; TS emits no constraint for the same metadata.2. Vocabulary regression — the opt-out is clamped shut
@requiredis documented as presence; Pin 1 redefined it as non-empty; and the natural escape hatch is deliberately foreclosed.validator.length @min: 0on a required string cannot suppress the floor:kwargs["min_length"] = max(1, existing_min)(above)codegen-ts/src/templates/zod-validators.ts— comments that an explicit@min: 0"can't suppress it"So "must be provided, but may be the empty string" is now inexpressible: dropping
@requiredflips the generated type toT | None = None, losing presence typing entirely. Note non-emptiness already had a home —validator.length @min: 1maps to exactlymin_length=1. Pin 1 duplicated an expressible constraint into@requiredwhile removing the ability to not say it.Aside on the whitespace rule. Pin 1 rejects
""but accepts" ". That isn't a semantic — a meaningful-content rule would trim, a presence rule would accept"". The spec states the origin plainly: "Matches TS, the reference impl." It's the shadow ofz.string().min(1). Reasonable as a cross-port consistency ruling for the wire tier; the problem is that it silently redefined an attribute documented as presence, everywhere.3. The documentation item was never executed
FR-036 §A5 explicitly ordered: "Reconcile the registry text: field
@requiredsays 'NOT NULL',validator.requiredsays 'null/empty' — align both to the pinned non-empty semantic."This did not happen. As shipped in v0.19.0:
spec/metamodel/field.json:13— "When true, the field is NOT NULL. Equivalent to attaching a validator.required child."server/typescript/packages/metadata/src/core/field/field-definition.embedded.ts:45— same textvalidator-definition.embedded.ts:38-39— "Fails when the value is null/empty (NOT NULL)" / "A field must be present (NOT NULL)" (internally contradictory, pre-existing)metaobjects-authoringskill never defines the non-empty semanticThe new meaning exists in prose only in the CHANGELOG breaking banner. An adopter reading the vocabulary today — post-change — would still author
required: trueto mean "must be present," which is precisely what happened.4. Latent: the entity READ model also carries the constraint
In Python the constraint lands on the entity base/read model too, so reading an existing DB row containing
""throws at model construction. TS's Zod equivalent is insert/update-only. Not the reported breakage, but the same over-application one tier down — worth gating in the same pass.Proposed fixes
object.entity(excludeOBJECT_SUBTYPE_VALUE) inentity_model.py. Restores FR-036's designed scope, TS parity, ADR-0028 purity, and Python-internal coherence. Add anobject.valuecase tofixtures/validation-conformance/so it stays locked.validator.length @min: 0on a required string as the opt-out — remove themax(1, …)clamp in Python and TS. An author writing@min: 0is unambiguously saying "empty allowed," so this needs no further breaking change. (A full revert of Pin 1 to presence-only would be semantically cleaner but is a second breaking change against conformance-locked behavior — not proposed here.)spec/metamodel/field.json,field-definition.embedded.ts,validator-definition.embedded.ts, and the authoring skill to state: strings under@requiredreject""(whitespace accepted), arrays do not, the entity-vs-value scope, and the opt-out.Environment
metaobjects0.19.0; TS@metaobjectsdev/*0.19.0;metamodelVersion0.9.object.valuepayloads see it the moment they regenerate.