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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ follow [SemVer](https://semver.org/).
(`{"keep": true}`) beside a typed text lane is no longer silently
laundered into a valid `{"edit": …}` that this surface alone
accepted while the collect path names it
- A display-only PROGRESS field (no blocked options) no longer
projects to `{"type": "string", "enum": []}` in the elicitation
schema — an empty enum is a property no value can satisfy (an
unanswerable field, or a whole-schema rejection on a strict
client). Such a report is narrated, not answered, so it is now
skipped from `properties`/`required` entirely; a PROGRESS that
carries blocked options is a real single-pick and still projects
- `form_from_template` now validates the `slots` argument type *before*
the `values = slots or {}` coalesce (confirmation-pass-2 needs-a-look,
2026-08-20). The pass-1 `isinstance(values, dict)` guard ran after the
Expand All @@ -143,6 +150,17 @@ follow [SemVer](https://semver.org/).
non-`dict` `slots` is now named directly; `None` still coalesces to
an empty mapping. `dict` stays strict so the "mapping" wording matches
what is accepted — a `Mapping` that is not a `dict` is rejected too
- The widget's `::` radio-group namespace is now collision-guarded at
definition time, symmetric with the existing dotted-key guard
(confirmation-pass-2 needs-a-look, 2026-08-20). A TRIAGE or
ASSUMPTION_REVIEW board with id `a` renders one radio group per item
named `a::<idx>`; a sibling field whose id was literally `a::1` emitted
a group sharing that `name`, so the browser fused the two into one
mutually-exclusive group and one field became unanswerable in the
widget. A field id colliding with a board's `a::<idx>` namespace is now
rejected at `form_from_dict` time, so the colliding HTML is never
rendered. Low realism (no author writes `::N` ids) but a genuine
unguarded namespace analogous to the guarded dotted one
- Confirmation-pass-1 batch (library review, 2026-08-20 — all eight
findings empirically confirmed before fixing):
- An assumption-review text lane whose item has NO ruling (a
Expand Down
29 changes: 29 additions & 0 deletions src/attune_forms/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,23 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema:
f"{owner.id!r}'s dotted answer namespace ('{owner.id}.<key>')"
)

# The widget's "::" radio-group namespace needs the SAME by-definition
# guard: a TRIAGE / ASSUMPTION_REVIEW board with id "a" renders one
# radio group per item named "a::<idx>", so a sibling field whose id is
# literally "a::1" emits a group sharing that name — the browser fuses
# them into one mutually-exclusive group and one field becomes
# unanswerable (confirmation-pass-2 finding, 2026-08-20). Reject at
# definition, symmetric with the dotted guard above.
for owner in questions:
if owner.type not in _WIDGET_RADIO_GROUP_TYPES:
continue
for question in questions:
if question.id != owner.id and question.id.startswith(f"{owner.id}::"):
problems.append(
f"field id {question.id!r} collides with {owner.type.value} "
f"{owner.id!r}'s widget radio-group namespace ('{owner.id}::<idx>')"
)

if problems:
raise FormValidationError(problems)

Expand Down Expand Up @@ -1015,6 +1032,18 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema:
{QuestionType.TRIAGE, QuestionType.RANKING, QuestionType.ASSUMPTION_REVIEW}
)

#: Types whose widget renders one radio *group per item*, named
#: ``"<field id>::<item index>"`` (see ``_control_triage_html`` /
#: ``_control_assumption_review_html``). That ``::`` group namespace is a
#: second reserved namespace — a sibling field whose literal id is
#: ``"<board id>::<N>"`` would emit a radio group sharing the board row's
#: ``name``, and the browser would treat them as ONE mutually-exclusive
#: group, making one field unanswerable (confirmation-pass-2 finding,
#: 2026-08-20). RANKING is in :data:`_EXPANDING_TYPES` but NOT here: its
#: widget groups by ``data-opt``, never by a ``::`` radio name, so it owns
#: no such namespace.
_WIDGET_RADIO_GROUP_TYPES = frozenset({QuestionType.TRIAGE, QuestionType.ASSUMPTION_REVIEW})

#: The strict subset with NO portable ``AskUserQuestion`` control at
#: all. A form using any of these cannot be asked on ``AskUserQuestion``
#: in any form — unlike the v3–v5 constructs, which are expressible but
Expand Down
11 changes: 11 additions & 0 deletions src/attune_forms/elicitation_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,17 @@ def form_to_elicitation_schema(form: FormSchema) -> dict[str, Any]:
if q.required:
required.append(f"{q.id}.{key}")
continue
if q.type is QuestionType.PROGRESS and not q.options:
# A display-only PROGRESS (no blocked options) has nothing to
# ask — the report is narrated, not answered. Projecting it
# would emit ``{"type": "string", "enum": []}``, a property no
# value can satisfy (an empty enum is unanswerable, and a
# strict client may reject the whole schema over it). Skip it
# entirely, mirroring the narrate-instead fallback in
# ``to_ask_user_format``. A PROGRESS that DOES carry blocked
# options is a real single-pick and still projects below
# (confirmation pass 2, 2026-08-20).
continue
properties[q.id] = _property_for(q)
if q.required:
required.append(q.id)
Expand Down
55 changes: 55 additions & 0 deletions tests/test_elicitation_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,58 @@ def test_directly_built_ranking_default_does_not_clobber_suggested(self):
# The visible proposal (suggested) survives; the illegal `default`
# never reaches the schema.
assert prop["default"] == ["C", "B", "A"]

def test_display_only_progress_is_skipped(self):
# A display-only PROGRESS (no blocked options) projected to
# `{"type": "string", "enum": []}` — an unanswerable property a
# strict client could reject the whole schema over. It carries no
# answer, so it must not appear in properties/required at all
# (confirmation pass 2, 2026-08-20).
form = form_from_dict(
{
"title": "T",
"fields": [
{
"id": "p",
"text": "Progress",
"type": "progress",
"required": False,
"progress_items": [
{"label": "A", "status": "done"},
{"label": "B", "status": "in_flight"},
],
}
],
}
)
schema = form_to_elicitation_schema(form)
assert "p" not in schema["properties"]
assert "p" not in schema["required"]

def test_progress_with_blocked_options_still_projects(self):
# A PROGRESS that carries blocked options is a real single-pick and
# must keep projecting as a string enum.
form = form_from_dict(
{
"title": "T",
"fields": [
{
"id": "p",
"text": "Progress",
"type": "progress",
"options": ["B"],
"progress_items": [
{"label": "A", "status": "done"},
{"label": "B", "status": "blocked"},
],
}
],
}
)
schema = form_to_elicitation_schema(form)
assert schema["properties"]["p"] == {
"title": "Progress",
"type": "string",
"enum": ["B"],
}
assert schema["required"] == ["p"]
28 changes: 28 additions & 0 deletions tests/test_triage_construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from __future__ import annotations

import re

import pytest

from attune_forms import (
Expand Down Expand Up @@ -286,6 +288,32 @@ def test_sibling_id_in_dotted_namespace_rejected(self) -> None:
with pytest.raises(FormValidationError, match="dotted answer namespace"):
form_from_dict(data)

def test_colliding_double_colon_field_id_rejected(self) -> None:
# The widget renders board "findings" as radio groups named
# "findings::0", "findings::1", ... A sibling field whose id is
# literally "findings::1" would emit a group sharing that name, so
# the browser fuses them into one mutually-exclusive group and one
# field becomes unanswerable (confirmation-pass-2, 2026-08-20).
# Reject at definition so the colliding HTML is never rendered.
data = _triage()
data["fields"].append(
{"id": "findings::1", "type": "text_input", "text": "n", "required": False}
)
with pytest.raises(FormValidationError, match="widget radio-group namespace"):
form_from_dict(data)

# And pin the collision the guard prevents on the rendered HTML:
# the board's three rows render as three DISTINCT group names
# (findings::0/1/2), each carrying only its own disposition radios.
# A colliding "findings::1" field would inject a foreign group of
# the same name — exactly what the definition guard now forbids.
html = form_to_widget_html(form_from_dict(_triage()))
names = re.findall(r'<input type="radio" name="([^"]+)"', html)
assert set(names) == {"findings::0", "findings::1", "findings::2"}
# 3 dispositions × 1 row each: the group name appears once per
# disposition and never bleeds across rows or a foreign field.
assert names.count("findings::1") == 3

def test_summary_renders_rulings_not_dict_repr(self) -> None:
from attune_forms import form_response_summary

Expand Down
Loading