Skip to content

FR-036 Pin 1 (@required string ⇒ non-empty) is over-applied to object.value in the Python port — TS excludes it, and the @min:0 opt-out is clamped shut #224

Description

@dmealing

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) else1

The 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:

  1. 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.
  2. The conformance corpus models only object.entity (fixtures/validation-conformance/meta.json) — so nothing locks the object.value behavior either way.
  3. The TS reference port excludes it explicitlyserver/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.
  4. 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 text
  • validator-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

  1. 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.
  2. Restore expressibility (additive). Honor an explicitvalidator.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.)
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:codegenCode generationarea:metamodelMetamodel vocabulary / loaderbugSomething isn't workingdocumentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions