Skip to content

min_items on a list field is not enforced when the field is absent #217

Description

@sorenwacker

Summary

min_items on a list field is only checked when the field is present and non-null in the record. When the key is absent the constraint is silently skipped, so a min_items derived from an ontology existential ("every programme has at least one project") is not enforced on any path where child collections are not inlined into the parent record.

Cause

ExtractionContext.validate_instance (src/metaseed/agent/core.py:416-426) checks required fields, then validates constraints only in the elif branch:

if field.required and (field.name not in data or data[field.name] is None):
    ...  # missing required field
elif field.name in data and data[field.name] is not None:
    field_errors = self._validate_field(data[field.name], field)

_validate_cardinality (core.py:531-556) is reached only from _validate_field, and additionally returns [] unless the value isinstance(value, list). An absent key therefore never reaches the cardinality check.

For an optional list this means min_items has no effect at all when the field is omitted, which is the normal case for a nesting list whose children are stored separately: EntityNode children are linked by parent_id (src/metaseed/facade/store.py) and the MCP validate_dataset tool validates each node from node.instance.model_dump(), so the parent record contains no child list to count. This is the same representation issue ADR 003 records as finding 3 for cardinality rules; it applies equally to the field-level constraint.

Reproduction

from metaseed.specs.schema import ProfileSpec
from metaseed.agent.core import ExtractionContext

spec = ProfileSpec.model_validate({
    "name": "repro", "version": "1.0", "root_entity": "Programme",
    "entities": {
        "Programme": {"description": "root", "fields": [
            {"name": "identifier", "type": "string", "required": True},
            {"name": "projects", "type": "list", "required": False,
             "items": "Project", "constraints": {"min_items": 1}},
        ]},
        "Project": {"description": "child", "fields": [
            {"name": "identifier", "type": "string", "required": True},
        ]},
    },
})
ctx = ExtractionContext(spec)
for rec in ({"identifier": "P1"},
            {"identifier": "P1", "projects": []},
            {"identifier": "P1", "projects": [{"identifier": "X"}]}):
    print(rec, "->", [i.message for i in ctx.validate_instance(rec, "Programme")])

Observed:

{'identifier': 'P1'}                                  -> []
{'identifier': 'P1', 'projects': []}                  -> ['Must have at least 1 item(s)']
{'identifier': 'P1', 'projects': [{...}]}             -> []

Expected: the first case is indistinguishable from the second in meaning — no projects — but only the second is reported.

Why it matters

Profiles derived from an ontology translate existential restrictions into required: true on scalar fields and min_items: 1 on nesting lists. The scalar half is enforced; the list half is not, on the path most datasets are actually validated through. A profile author reading the spec sees a constraint that does not run, which is worse than no constraint, because it reads as covered.

Possible directions

  1. Treat an absent list as an empty list for cardinality purposes, so min_items fires on omission. This changes behaviour for every existing profile using min_items, so it needs a blast-radius check against shipped profiles first.
  2. Count children from the facade store rather than the parent record when the field is a nesting field, which fixes the representation mismatch rather than the symptom, and would also address ADR 003 finding 3.
  3. Reject min_items on an optional nesting field at spec-validation time, on the grounds that it cannot be enforced there, and report it as a spec warning.

Whichever is chosen, docs/discussion/ontology-to-profile.md records this as an open issue and should be updated to state the settled behaviour.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions