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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ follow [SemVer](https://semver.org/).
fallback, matching what the code reads

### Fixed
- 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 —
the contradicting dotted value used to vanish, the same silent-drop
class #39/#40 named for rank slots (chair ruling on
confirmation-pass-1, 2026-08-20)
- A DELIBERATION whose `endorsements` is the empty mapping `{}` is a
named definition problem — it satisfied the required check vacuously,
yielding exactly the "just a decision, no endorsements" the field
exists to prevent (chair ruling on confirmation-pass-1, 2026-08-20)
- Documented (chair ruling to keep, 2026-08-20): an explicit empty
answer (`""`, `[]`, `{}`) on a field with a `default` collects the
default — empty is the accept-the-default gesture, indistinguishable
from an untouched prefill, so a surface needing a clearable field
must not prefill it via `default`
- Author-supplied field text can no longer desync the markdown reply
skeleton (confirmation-pass-2 needs-a-look, 2026-08-20 — the LOUD
sibling of the pass-2 silent-injection fix). A literal triple-backtick
Expand Down
53 changes: 40 additions & 13 deletions src/attune_forms/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,11 +357,13 @@ def _parse_endorsements(
where: str, raw: dict[str, Any], qtype: QuestionType, options: list[str]
) -> tuple[dict[str, list[str]] | None, list[str]]:
"""Parse the v6 DELIBERATION extra ``endorsements``: {option: [voice,
...]} naming which deliberating voices back each option. Required for
DELIBERATION (without it the construct is just a decision), invalid
elsewhere. Keys must be options; each value a non-empty list of
non-empty names. Options nobody endorsed are allowed — the chair may
table a position no voice proposed.
...]} naming which deliberating voices back each option. Required
and non-empty for DELIBERATION (without any endorsement the
construct is just a decision — ``{}`` used to satisfy the check
vacuously; chair ruling 2026-08-20), invalid elsewhere. Keys must be
options; each value a non-empty list of non-empty names. Options
nobody endorsed are allowed — the chair may table a position no
voice proposed.
"""
endorsements = raw.get("endorsements")
if endorsements is None:
Expand All @@ -378,6 +380,11 @@ def _parse_endorsements(
for opt, names in endorsements.items()
):
return None, [f"{where} 'endorsements' must map option -> non-empty list of names"]
if not endorsements:
return None, [
f"{where} type deliberation requires at least one endorsement "
"(an empty 'endorsements' is just a decision)"
]
stray = [opt for opt in endorsements if opt not in options]
if stray:
return None, [f"{where} 'endorsements' keys not in options: {stray}"]
Expand Down Expand Up @@ -1685,7 +1692,13 @@ def _fold_expanded_answers(
ranking as ONE bounded array property, see ``_EXPANDING_TYPES``).
This pre-pass rebuilds the canonical ``{key: disposition}`` mapping /
ordered list so every surface funnels into the same validator. An
answer already present under the question id wins; the input dict is
answer already present under the question id makes any dotted
sibling a named problem — the old canonical-wins rule silently
discarded a contradicting dotted value, the same silent-drop class
the slot-collision rule below names (confirmation-pass-1 finding,
chair ruling 2026-08-20). The markdown surface merges its dotted
rows into the canonical shape before handing off, so a mixed shape
here means a confused caller, not a typed reply. The input dict is
never mutated.

Ranking slots fold in slot order. EVERY decimal slot suffix folds —
Expand All @@ -1700,21 +1713,29 @@ def _fold_expanded_answers(
where a human types one, names it as an unknown rank slot at parse
time.

Returns ``(folded, problems)``. The only fold-time problem is two
keys claiming the same rank slot (``"r.01"`` and ``"r.1"`` both fold
to slot 1): the same silent-drop class as the over-long ranking
above, so it is named instead of letting an arbitrary winner
validate clean (pilot review finding, 2026-08-19).
Returns ``(folded, problems)``. Fold-time problems are the mixed
canonical/dotted shape above and two keys claiming the same rank
slot (``"r.01"`` and ``"r.1"`` both fold to slot 1): the same
silent-drop class as the over-long ranking above, so it is named
instead of letting an arbitrary winner validate clean (pilot review
finding, 2026-08-19).
"""
expanding = [q for q in form.questions if q.type in _EXPANDING_TYPES]
if not expanding:
return raw_answers, []
problems: list[str] = []
folded = dict(raw_answers)
for question in expanding:
prefix = f"{question.id}."
if question.id in folded:
dotted = sorted(key for key in folded if key.startswith(prefix))
if dotted:
problems.append(
f"{question.id!r} is supplied both canonically and as "
f"dotted keys ({', '.join(map(repr, dotted))}); "
"supply one shape"
)
continue
prefix = f"{question.id}."
if question.type is QuestionType.TRIAGE:
picks = {
key[len(prefix) :]: folded.pop(key)
Expand Down Expand Up @@ -1767,7 +1788,13 @@ def collect_form_response(
default passes the same per-type validator an answer would, so a
directly-built form (bypassing ``form_from_dict``'s definition-time
check) still cannot launder an invalid default into a validated
response. An answer key that matches no question id and no expanding
response. A provided answer that is EMPTY (``""``, ``[]``, ``{}``)
takes the same default path as an omitted key: empty is the
accept-the-default gesture (enter through a prompt, an untouched
widget), and this side of the wire cannot tell a deliberately
cleared prefill from an untouched one — so a surface that needs a
clearable field must not prefill it via ``default`` (chair ruling,
2026-08-20). An answer key that matches no question id and no expanding
question's dotted namespace (``"<id>.<key>"``) is named as unknown —
a typo'd key against an optional-with-default field would otherwise
silently collect the default (pilot review finding, 2026-08-19).
Expand Down
46 changes: 37 additions & 9 deletions tests/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,27 @@ def test_optional_missing_uses_default(self):
resp = collect_form_response(form, {})
assert resp.responses["a"] == "fallback"

def test_explicit_empty_answer_accepts_the_default(self):
# Pinned by chair ruling 2026-08-20 (confirmation-pass-1
# needs-a-look): an explicit "" is the accept-the-default
# gesture, indistinguishable from an untouched prefill — a
# surface that needs a clearable field must not prefill it via
# `default`. Documented in collect_form_response's docstring.
data = {
"title": "T",
"fields": [
{
"id": "a",
"text": "A?",
"type": "text_input",
"required": False,
"default": "fallback",
}
],
}
resp = collect_form_response(form_from_dict(data), {"a": ""})
assert resp.responses["a"] == "fallback"

def test_optional_missing_no_default_omitted(self):
data = {
"title": "T",
Expand Down Expand Up @@ -329,7 +350,9 @@ class TestUnknownAnswerKeys:
key matching no question id was silently ignored — a typo'd key
against an optional-with-default field invisibly collected the
default. Unknown keys are now named; keys inside an expanding
question's dotted namespace stay exempt (the fold owns them)."""
question's dotted namespace stay exempt from the UNKNOWN check (the
fold owns them) — but coexisting with a canonical answer they are a
named contradiction (chair ruling, 2026-08-20)."""

def test_typoed_key_is_named(self):
field = {
Expand All @@ -344,9 +367,11 @@ def test_typoed_key_is_named(self):
with pytest.raises(FormValidationError, match="unknown answer key 'aproach'"):
collect_form_response(form, {"aproach": "yolo"})

def test_dotted_keys_under_present_mapping_stay_exempt(self):
# Mapping-wins-over-dotted leaves the dotted keys unfolded; they
# are declared namespace, not typos.
def test_dotted_keys_under_present_mapping_are_a_named_contradiction(self):
# Chair ruling 2026-08-20 (confirmation-pass-1): the old
# canonical-wins rule silently discarded a contradicting dotted
# sibling — same silent-drop class as #39/#40. Mixed shapes are
# now a named problem, not an arbitrary winner.
form = form_from_dict(
{
"title": "T",
Expand All @@ -361,8 +386,11 @@ def test_dotted_keys_under_present_mapping_stay_exempt(self):
],
}
)
response = collect_form_response(
form,
{"board": {"One": "keep", "Two": "drop"}, "board.One": "drop"},
)
assert response.responses["board"] == {"One": "keep", "Two": "drop"}
with pytest.raises(
FormValidationError,
match=r"'board' is supplied both canonically and as dotted keys \('board.One'\)",
):
collect_form_response(
form,
{"board": {"One": "keep", "Two": "drop"}, "board.One": "drop"},
)
7 changes: 7 additions & 0 deletions tests/test_deliberation_construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ def test_requires_endorsements(self) -> None:
with pytest.raises(FormValidationError, match="requires 'endorsements'"):
form_from_dict(_deliberation(endorsements=None))

def test_empty_endorsements_rejected(self) -> None:
# Chair ruling 2026-08-20 (confirmation-pass-1): {} satisfied
# the required check vacuously — exactly the "just a decision,
# no endorsements" the field exists to prevent.
with pytest.raises(FormValidationError, match="requires at least one endorsement"):
form_from_dict(_deliberation(endorsements={}))

def test_endorsement_keys_must_be_options(self) -> None:
with pytest.raises(FormValidationError, match="'endorsements' keys not in options"):
form_from_dict(_deliberation(endorsements={"Ghost": ["claude"]}))
Expand Down
11 changes: 11 additions & 0 deletions tests/test_ranking_construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,17 @@ def test_canonical_dotted_slots_still_fold(self) -> None:
)
assert response.responses["prio"] == ["auth", "billing", "search", "docs"]

def test_canonical_list_plus_dotted_slot_is_a_named_contradiction(self) -> None:
# Chair ruling 2026-08-20 (confirmation-pass-1): the fold's
# canonical-wins rule silently discarded a contradicting dotted
# slot; mixed shapes are now a named problem.
form = form_from_dict(_ranking())
with pytest.raises(FormValidationError, match="supplied both canonically and as dotted"):
collect_form_response(
form,
{"prio": ["auth", "billing", "search", "docs"], "prio.1": "docs"},
)

def test_non_decimal_suffix_still_ignored(self) -> None:
"""A non-decimal suffix is not a slot key; it lives inside the
question's declared dotted namespace, so the unknown-key check
Expand Down
14 changes: 8 additions & 6 deletions tests/test_triage_construct.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,15 @@ def test_flat_dotted_answers_fold_into_the_mapping(self) -> None:
"Naming nit": "dismiss",
}

def test_mapping_answer_wins_over_dotted(self) -> None:
def test_mapping_plus_dotted_is_a_named_contradiction(self) -> None:
# Chair ruling 2026-08-20: mapping-wins used to silently discard
# the contradicting dotted value; mixed shapes are now named.
form = form_from_dict(_triage(required=False))
resp = collect_form_response(
form,
{"findings": {"retry": "ticket"}, "findings.retry": "dismiss"},
)
assert resp.responses["findings"] == {"retry": "ticket"}
with pytest.raises(FormValidationError, match="supplied both canonically and as dotted"):
collect_form_response(
form,
{"findings": {"retry": "ticket"}, "findings.retry": "dismiss"},
)

def test_fold_never_mutates_the_input(self) -> None:
form = form_from_dict(_triage(required=False))
Expand Down
Loading