Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ follow [SemVer](https://semver.org/).
fallback, matching what the code reads

### Fixed
- Unknown DEFINITION keys are now named problems instead of silently
ignored (confirmation-pass-1 chair ruling, 2026-08-20): a typo'd
field key (`"maximun": 10`) built a bound-less field that validated
any answer clean. `form_from_dict` rejects every unrecognized
top-level and field-level key (`unknown definition key '...'`,
mirroring #37's answer-side wording; the `label`/`questions` aliases
stay accepted), and the MCP `inputSchema` declares
`additionalProperties: false` on both the form and field objects so
the SDK gate agrees with the parser. The schema is D3-mirrored to
attune-ai — the mirror must pick up `additionalProperties` at the
next release-gated re-sync
- Supplying an expanding question's answer both canonically and as
dotted keys (`{"t": {...}}` plus `"t.i2": "skip"`) is a named
validation problem instead of the canonical answer silently winning —
Expand Down
55 changes: 53 additions & 2 deletions src/attune_forms/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,12 +796,54 @@ def _parse_list_style(
return list_style, []


# The definition-side twin of the #37 answer-side gate: a key the field
# parsers never read is a typo or an unsupported construct, and silently
# ignoring it invents a constraint that does not exist ("maximun": 10 →
# the bound is never built and collect(99999) validates clean). These
# sets must track exactly what form_from_dict and its _parse_* helpers
# read; the parity test against the MCP _field_schema ratchets that.
_DEFINITION_TOP_KEYS = frozenset({"title", "description", "fields", "questions"})
_DEFINITION_FIELD_KEYS = frozenset(
{
"id",
"text",
"label",
"type",
"options",
"default",
"help_text",
"required",
"minimum",
"maximum",
"max_length",
"rationale",
"recommended",
"option_notes",
"user_position",
"progress_items",
"progress_style",
"endorsements",
"triage_items",
"dispositions",
"suggested",
"consequences",
"list_style",
"inferred_from",
"top_n",
"assumptions",
}
)


def form_from_dict(data: dict[str, Any]) -> FormSchema:
"""Build a :class:`FormSchema` from plain serializable data (D3).

The declarative artifact a skill / future designer / data source
produces. Validates the form *definition* (not answers) and raises
:class:`FormValidationError` listing every problem.
:class:`FormValidationError` listing every problem. A key the
parser does not read — top-level or field-level — is a definition
problem, not ignorable extra data: a typo'd ``"maximun"`` would
otherwise silently drop the bound it meant to declare.

Args:
data: ``{"title": str, "description"?: str, "fields": [ ... ]}``.
Expand All @@ -814,7 +856,8 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema:
A validated :class:`FormSchema`.

Raises:
FormValidationError: If the definition is malformed.
FormValidationError: If the definition is malformed, including
any unknown definition key.
"""
problems: list[str] = []

Expand All @@ -830,6 +873,10 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema:
problems.append("form must have a non-empty 'fields' list")
raw_fields = []

for key in data:
if key not in _DEFINITION_TOP_KEYS:
problems.append(f"form has unknown definition key {key!r}")

seen_ids: set[str] = set()
questions: list[FormQuestion] = []
for idx, raw in enumerate(raw_fields):
Expand All @@ -838,6 +885,10 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema:
problems.append(f"{where} must be a mapping")
continue

for key in raw:
if key not in _DEFINITION_FIELD_KEYS:
problems.append(f"{where} unknown definition key {key!r}")

fid, text, id_problems = _parse_field_identity(where, raw, seen_ids)
problems.extend(id_problems)

Expand Down
6 changes: 6 additions & 0 deletions src/attune_forms/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ def _field_schema() -> dict[str, Any]:
"list_style": {"type": "string", "enum": ["ordered", "unordered"]},
},
"required": ["id", "text", "type"],
# Mirrors form_from_dict's strict definition contract: an
# unknown field key is a typo ("maximun") that would silently
# drop the constraint it meant to declare, so the SDK gate
# rejects it rather than waving it through to a lax parse.
"additionalProperties": False,
}


Expand All @@ -179,6 +184,7 @@ def _form_schema() -> dict[str, Any]:
"fields": {"type": "array", "items": _field_schema()},
},
"required": ["title", "fields"],
"additionalProperties": False,
}


Expand Down
64 changes: 64 additions & 0 deletions tests/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,67 @@ def test_dotted_keys_under_present_mapping_are_a_named_contradiction(self):
form,
{"board": {"One": "keep", "Two": "drop"}, "board.One": "drop"},
)


class TestUnknownDefinitionKeys:
"""Pinned from the confirmation-pass-1 chair ruling (2026-08-20):
the definition-side twin of TestUnknownAnswerKeys. A key the parser
never reads was silently ignored — a typo'd 'maximun' built a
bound-less number field that validated any answer clean. Unknown
top-level and field-level keys are now named definition problems."""

def test_ledger_repro_typoed_maximum_is_named(self):
# The exact confirmation-pass repro: the typo'd bound must not
# silently vanish into an unconstrained field.
with pytest.raises(
FormValidationError, match="field\\[0\\] unknown definition key 'maximun'"
):
form_from_dict(
{
"title": "T",
"fields": [{"id": "n", "text": "N", "type": "number", "maximun": 10}],
}
)

def test_every_unknown_field_key_is_named(self):
with pytest.raises(FormValidationError) as exc:
form_from_dict(
{
"title": "T",
"fields": [
{
"id": "q",
"text": "Q",
"type": "text_input",
"regired": True,
"recomended": "x",
}
],
}
)
problems = exc.value.problems
assert "field[0] unknown definition key 'regired'" in problems
assert "field[0] unknown definition key 'recomended'" in problems

def test_top_level_unknown_key_is_named(self):
with pytest.raises(
FormValidationError, match="form has unknown definition key 'descripton'"
):
form_from_dict(
{
"title": "T",
"descripton": "typo",
"fields": [{"id": "q", "text": "Q", "type": "text_input"}],
}
)

def test_documented_aliases_stay_accepted(self):
# 'label' (for 'text') and 'questions' (for 'fields') are
# documented aliases, not strays.
form = form_from_dict(
{
"title": "T",
"questions": [{"id": "q", "label": "Q", "type": "text_input"}],
}
)
assert form.questions[0].text == "Q"
22 changes: 22 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,28 @@ def test_schema_accepts_legal_triage_object_default():
jsonschema.validate({"form": form}, tools["elicitation_render_form"].inputSchema)


def test_schemas_close_unknown_keys():
"""Confirmation-pass-1 chair ruling (2026-08-20): unknown definition
keys are strictly rejected at BOTH layers — the advertised schema
must not wave through what form_from_dict names as a problem."""
from attune_forms.mcp_server import _field_schema, _form_schema

assert _field_schema()["additionalProperties"] is False
assert _form_schema()["additionalProperties"] is False


def test_field_schema_matches_parser_key_set():
"""Ratchet: the mirrored _field_schema and form_from_dict's strict
key set may not drift apart. 'label' is the parser-side alias for
'text' — never advertised (the schema requires 'text', so the alias
was never usable over stdio)."""
from attune_forms.bridge import _DEFINITION_FIELD_KEYS
from attune_forms.mcp_server import _field_schema

advertised = set(_field_schema()["properties"])
assert advertised | {"label"} == set(_DEFINITION_FIELD_KEYS)


def test_field_schema_covers_the_whole_grammar():
"""Drift catcher (architecture review F5, 2026-08-20): the
hand-maintained tool schema must keep up with the grammar — every
Expand Down
Loading