diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2a585..cc2c1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/attune_forms/bridge.py b/src/attune_forms/bridge.py index ae09134..d8766a5 100644 --- a/src/attune_forms/bridge.py +++ b/src/attune_forms/bridge.py @@ -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: @@ -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}"] @@ -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 — @@ -1700,11 +1713,12 @@ 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: @@ -1712,9 +1726,16 @@ def _fold_expanded_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) @@ -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 (``"."``) 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). diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 92b46fe..a1bff31 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -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", @@ -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 = { @@ -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", @@ -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"}, + ) diff --git a/tests/test_deliberation_construct.py b/tests/test_deliberation_construct.py index b1aad2c..b477acc 100644 --- a/tests/test_deliberation_construct.py +++ b/tests/test_deliberation_construct.py @@ -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"]})) diff --git a/tests/test_ranking_construct.py b/tests/test_ranking_construct.py index 2e0b92d..f116b28 100644 --- a/tests/test_ranking_construct.py +++ b/tests/test_ranking_construct.py @@ -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 diff --git a/tests/test_triage_construct.py b/tests/test_triage_construct.py index a10c33b..231d4d5 100644 --- a/tests/test_triage_construct.py +++ b/tests/test_triage_construct.py @@ -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))